diff options
667 files changed, 48648 insertions, 640 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml index e8bfde299..107ed1248 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -121,7 +121,10 @@ jobs: steps: - *attach_workspace - *install_system_dependencies - - run: ./bin/rails assets:precompile + - run: + name: Precompile assets + command: ./bin/rails assets:precompile + no_output_timeout: 40m - persist_to_workspace: root: ~/projects/ paths: diff --git a/.env.production.sample b/.env.production.sample index d66b05050..25ab1b9de 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -153,6 +153,12 @@ STREAMING_CLUSTER_NUM=1 # If you use Docker, you may want to assign UID/GID manually. # UID=1000 # GID=1000 + +# Maximum allowed character count +# MAX_TOOT_CHARS=500 + +# Maximum number of pinned posts +# MAX_PINNED_TOOTS=5 # LDAP authentication (optional) # LDAP_ENABLED=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f49964bd9..ceb737075 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,27 +1,12 @@ --- name: Bug Report -about: If something isn't working as expected +about: Create a report to help us improve --- -<!-- Make sure that you are submitting a new bug that was not previously reported or already fixed --> +[Issue text goes here]. -<!-- Please use a concise and distinct title for the issue --> +* * * * -### Expected behaviour - -<!-- What should have happened? --> - -### Actual behaviour - -<!-- What happened? --> - -### Steps to reproduce the problem - -<!-- What were you trying to do? --> - -### Specifications - -<!-- What version or commit hash of Mastodon did you find this bug in? --> - -<!-- If a front-end issue, what browser and operating systems were you using? --> +- [ ] I searched or browsed the repo’s other issues to ensure this is not a duplicate. +- [ ] This bugs also occur on vanilla Mastodon diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/.gitmodules diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 7cec57180..4b20fe971 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at eugen@zeonfederated.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at beatrix.bitrot@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65366be5f..7eceaf142 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,39 @@ +# 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 +======= Contributing -============ Thank you for considering contributing to Mastodon 🐘 @@ -37,3 +71,5 @@ The smaller the set of changes in the pull request is, the quicker it can be rev ## Documentation The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/docs](https://source.joinmastodon.org/mastodon/docs). + +</blockquote> diff --git a/Gemfile b/Gemfile index 998a6b816..3ed4989c8 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,7 @@ gem 'fastimage' gem 'goldfinger', '~> 2.1' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.5' +gem 'html2text' gem 'htmlentities', '~> 4.3' gem 'http', '~> 3.3' gem 'http_accept_language', '~> 2.1' diff --git a/Gemfile.lock b/Gemfile.lock index bfe8c558f..b170b9aa3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -259,6 +259,8 @@ GEM highline (2.0.1) hiredis (0.6.3) hkdf (0.3.0) + html2text (0.2.1) + nokogiri (~> 1.6) htmlentities (4.3.4) http (3.3.0) addressable (~> 2.3) @@ -693,6 +695,7 @@ DEPENDENCIES goldfinger (~> 2.1) hamlit-rails (~> 0.2) hiredis (~> 0.6) + html2text htmlentities (~> 4.3) http (~> 3.3) http_accept_language (~> 2.1) diff --git a/README.md b/README.md index 03c8472b9..470e379dc 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,12 @@ -![Mastodon](https://i.imgur.com/NhZc40l.png) -======== +# Mastodon Glitch Edition # -[![GitHub release](https://img.shields.io/github/release/tootsuite/mastodon.svg)][releases] -[![Build Status](https://img.shields.io/circleci/project/github/tootsuite/mastodon.svg)][circleci] -[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate] -[![Translation status](https://weblate.joinmastodon.org/widgets/mastodon/-/svg-badge.svg)][weblate] -[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker] +> Now with automated deploys! -[releases]: https://github.com/tootsuite/mastodon/releases -[circleci]: https://circleci.com/gh/tootsuite/mastodon -[code_climate]: https://codeclimate.com/github/tootsuite/mastodon -[weblate]: https://weblate.joinmastodon.org/engage/mastodon/ -[docker]: https://hub.docker.com/r/tootsuite/mastodon/ +[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci] -Mastodon is a **free, open-source social network server** based on ActivityPub. Follow friends and discover new ones. Publish anything you want: links, pictures, text, video. All servers of Mastodon are interoperable as a federated network, i.e. users on one server can seamlessly communicate with users from another one. This includes non-Mastodon software that also implements ActivityPub! +[circleci]: https://circleci.com/gh/glitch-soc/mastodon -Click below to **learn more** in a video: +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? -[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo] - -[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE - -## Navigation - -- [Project homepage 🐘](https://joinmastodon.org) -- [Support the development via Patreon][patreon] -- [View sponsors](https://joinmastodon.org/sponsors) -- [Blog](https://blog.joinmastodon.org) -- [Documentation](https://docs.joinmastodon.org) -- [Browse Mastodon servers](https://joinmastodon.org/#getting-started) -- [Browse Mastodon apps](https://joinmastodon.org/apps) - -[patreon]: https://www.patreon.com/mastodon - -## Features - -<img src="https://docs.joinmastodon.org/elephant.svg" align="right" width="30%" /> - -**No vendor lock-in: Fully interoperable with any conforming platform** - -It doesn't have to be Mastodon, whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/) - -**Real-time, chronological 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! - -**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! - -**Safety and moderation tools** - -Private posts, locked accounts, phrase filtering, muting, blocking and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) - -**OAuth2 and a straightforward REST API** - -Mastodon acts as an OAuth2 provider so 3rd party apps can use the REST and Streaming APIs, resulting in a rich app ecosystem with a lot of choice! - -## Deployment - -**Tech stack:** - -- **Ruby on Rails** powers the REST API and other web pages -- **React.js** and Redux are used for the dynamic parts of the interface -- **Node.js** powers the streaming API - -**Requirements:** - -- **PostgreSQL** 9.5+ -- **Redis** -- **Ruby** 2.4+ -- **Node.js** 8+ - -The repository includes deployment configurations for **Docker and docker-compose**, but also a few specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. The [**stand-alone** installation guide](https://docs.joinmastodon.org/administration/installation/) is available in the documentation. - -A **Vagrant** configuration is included for development purposes. - -## Contributing - -Mastodon is **free, open source software** licensed under **AGPLv3**. - -You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository, or submit translations using Weblate. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). - -**IRC channel**: #mastodon on irc.freenode.net - -## License - -Copyright (C) 2016-2019 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) - -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. +- 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 c4941f673..f7195a9c1 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -95,7 +95,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"] # Increase the number of CPUs. Uncomment and adjust to # increase performance # vb.customize ["modifyvm", :id, "--cpus", "3"] diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 52a51fd62..5850bd56d 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class AboutController < ApplicationController + before_action :set_pack layout 'public' before_action :set_instance_presenter, only: [:show, :more, :terms] @@ -24,6 +25,10 @@ class AboutController < ApplicationController helper_method :new_user + def set_pack + use_pack 'common' + end + def set_instance_presenter @instance_presenter = InstancePresenter.new end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index abc68d2a4..fcdebb47f 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -10,6 +10,7 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do + use_pack 'public' mark_cacheable! unless user_signed_in? @body_classes = 'with-modals' @@ -35,7 +36,7 @@ class AccountsController < ApplicationController mark_cacheable! @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]) - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) + render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? || entry.status.local_only? })) end format.rss do @@ -70,7 +71,7 @@ class AccountsController < ApplicationController end def default_statuses - @account.statuses.where(visibility: [:public, :unlisted]) + @account.statuses.not_local_only.where(visibility: [:public, :unlisted]) end def only_media_scope diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 7b81a2b01..cc6cd51f0 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -8,6 +8,7 @@ module Admin layout 'admin' before_action :require_staff! + before_action :set_pack before_action :set_body_classes private @@ -16,6 +17,10 @@ module Admin @body_classes = 'admin' end + def set_pack + use_pack 'admin' + end + def set_user @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index f23ed1508..aedfeb70e 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -30,6 +30,7 @@ module Admin @trending_hashtags = TrendingTags.get(7) @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview + @keybase_integration = Setting.enable_keybase end private diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb new file mode 100644 index 000000000..1cab3c372 --- /dev/null +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class Api::V1::BookmarksController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_bookmarks + end + + def cached_bookmarks + cache_collection( + Status.reorder(nil).joins(:bookmarks).merge(results), + Status + ) + end + + def results + @_results ||= account_bookmarks.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def account_bookmarks + current_account.bookmarks + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless results.empty? + api_v1_bookmarks_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index 054172bee..e5ac45fef 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -38,6 +38,6 @@ class Api::V1::ListsController < Api::BaseController end def list_params - params.permit(:title) + params.permit(:title, :replies_policy) end end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index df6c8e86c..3b3a39943 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -8,16 +8,25 @@ 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 paginated_mutes.map(&:target_account) end + def load_mutes + paginated_mutes.includes(:account, :target_account).to_a + end + def paginated_mutes @paginated_mutes ||= Mute.eager_load(:target_account) .where(account: current_account) @@ -34,26 +43,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 paginated_mutes.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 - paginated_mutes.last.id + if params[:action] == "details" + @mutes.last.id + else + paginated_mutes.last.id + end end def pagination_since_id - paginated_mutes.first.id + if params[:action] == "details" + @mutes.first.id + else + paginated_mutes.first.id + end end def records_continue? - paginated_mutes.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 e2dec62af..3b492c516 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -25,11 +25,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/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb new file mode 100644 index 000000000..bb9729cf5 --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarksController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' } + before_action :require_user! + + respond_to :json + + def create + @status = bookmarked_status + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + @status = requested_status + @bookmarks_map = { @status.id => false } + + bookmark = Bookmark.find_by!(account: current_user.account, status: @status) + bookmark.destroy! + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map) + end + + private + + def bookmarked_status + authorize_with current_user.account, requested_status, :show? + + bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status) + + bookmark.status.reload + end + + def requested_status + Status.find(params[:status_id]) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 990aff857..5401b9d59 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,7 +13,8 @@ class ApplicationController < ActionController::Base helper_method :current_account helper_method :current_session - helper_method :current_theme + helper_method :current_flavour + helper_method :current_skin helper_method :single_user_mode? helper_method :use_seamless_external_login? @@ -56,6 +57,75 @@ class ApplicationController < ActionController::Base new_user_session_path end + def pack(data, pack_name, skin = 'default') + return nil unless pack?(data, pack_name) + pack_data = { + common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin), + flavour: data['name'], + pack: pack_name, + preload: nil, + skin: nil, + supported_locales: data['locales'], + } + if data['pack'][pack_name].is_a?(Hash) + pack_data[:common] = nil if data['pack'][pack_name]['use_common'] == false + pack_data[:pack] = nil unless data['pack'][pack_name]['filename'] + if data['pack'][pack_name]['preload'] + pack_data[:preload] = [data['pack'][pack_name]['preload']] if data['pack'][pack_name]['preload'].is_a?(String) + pack_data[:preload] = data['pack'][pack_name]['preload'] if data['pack'][pack_name]['preload'].is_a?(Array) + end + if skin != 'default' && data['skin'][skin] + pack_data[:skin] = skin if data['skin'][skin].include?(pack_name) + else # default skin + pack_data[:skin] = 'default' if data['pack'][pack_name]['stylesheet'] + end + end + pack_data + end + + def pack?(data, pack_name) + if data['pack'].is_a?(Hash) && data['pack'].key?(pack_name) + return true if data['pack'][pack_name].is_a?(String) || data['pack'][pack_name].is_a?(Hash) + end + false + end + + def nil_pack(data, pack_name, skin = 'default') + { + common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin), + flavour: data['name'], + pack: nil, + preload: nil, + skin: nil, + supported_locales: data['locales'], + } + end + + def resolve_pack(data, pack_name, skin = 'default') + result = pack(data, pack_name, skin) + unless result + if data['name'] && data.key?('fallback') + if data['fallback'].nil? + return nil_pack(data, pack_name, skin) + elsif data['fallback'].is_a?(String) && Themes.instance.flavour(data['fallback']) + return resolve_pack(Themes.instance.flavour(data['fallback']), pack_name) + elsif data['fallback'].is_a?(Array) + data['fallback'].each do |fallback| + return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback) + end + end + return nil_pack(data, pack_name, skin) + end + return data.key?('name') && data['name'] != Setting.default_settings['flavour'] ? resolve_pack(Themes.instance.flavour(Setting.default_settings['flavour']), pack_name) : nil_pack(data, pack_name, skin) + end + result + end + + def use_pack(pack_name) + @core = resolve_pack(Themes.instance.core, pack_name) + @theme = resolve_pack(Themes.instance.flavour(current_flavour), pack_name, current_skin) + end + protected def truthy_param?(key) @@ -98,9 +168,14 @@ class ApplicationController < ActionController::Base @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) end - def current_theme - return Setting.theme unless Themes.instance.names.include? current_user&.setting_theme - current_user.setting_theme + def current_flavour + return Setting.flavour unless Themes.instance.flavours.include? current_user&.setting_flavour + current_user.setting_flavour + end + + def current_skin + return Setting.skin unless Themes.instance.skins_for(current_flavour).include? current_user&.setting_skin + current_user.setting_skin end def cache_collection(raw, klass) @@ -129,6 +204,7 @@ class ApplicationController < ActionController::Base format.html do set_locale + use_pack 'error' render "errors/#{code}", layout: 'error', status: code end end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index c28c7471c..eade82e36 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -5,6 +5,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController before_action :set_body_classes before_action :set_user, only: [:finish_signup] + before_action :set_pack def finish_signup return unless request.patch? && params[:user] @@ -20,6 +21,10 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController private + def set_pack + use_pack 'auth' + end + def set_user @user = current_user end diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 34b98da53..a59806f0d 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -2,6 +2,7 @@ class Auth::PasswordsController < Devise::PasswordsController before_action :check_validity_of_reset_password_token, only: :edit + before_action :set_pack before_action :set_body_classes layout 'auth' @@ -22,4 +23,8 @@ class Auth::PasswordsController < Devise::PasswordsController def reset_password_token_is_valid? resource_class.with_reset_password_token(params[:reset_password_token]).present? end + + def set_pack + use_pack 'auth' + end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 83797cf1f..c56728464 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_invite, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] + before_action :set_pack before_action :set_sessions, only: [:edit, :update] before_action :set_instance_presenter, only: [:new, :create, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update] @@ -82,6 +83,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController private + def set_pack + use_pack %w(edit update).include?(action_name) ? 'admin' : 'auth' + end + def set_instance_presenter @instance_presenter = InstancePresenter.new end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index fb8615c31..332f4d7a7 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :check_user_permissions, only: [:destroy] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + prepend_before_action :set_pack before_action :set_instance_presenter, only: [:new] before_action :set_body_classes @@ -108,6 +109,10 @@ class Auth::SessionsController < Devise::SessionsController private + def set_pack + use_pack 'auth' + end + def set_instance_presenter @instance_presenter = InstancePresenter.new end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index e27366ea3..20b3fa94b 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -8,6 +8,7 @@ class AuthorizeInteractionsController < ApplicationController before_action :authenticate_user! before_action :set_body_classes before_action :set_resource + before_action :set_pack def show if @resource.is_a?(Account) @@ -63,4 +64,8 @@ class AuthorizeInteractionsController < ApplicationController def set_body_classes @body_classes = 'modal-layout' end + + def set_pack + use_pack 'modal' + end end diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index 594907674..59247a21f 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -8,6 +8,7 @@ class DirectoriesController < ApplicationController before_action :set_tag, only: :show before_action :set_tags before_action :set_accounts + before_action :set_pack def index render :index @@ -19,6 +20,10 @@ class DirectoriesController < ApplicationController private + def set_pack + use_pack 'share' + end + def check_enabled return not_found unless Setting.profile_directory end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index d2e0fb739..f1e110d87 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -7,6 +7,7 @@ class FiltersController < ApplicationController before_action :set_filters, only: :index before_action :set_filter, only: [:edit, :update, :destroy] + before_action :set_pack before_action :set_body_classes def index @@ -44,6 +45,10 @@ class FiltersController < ApplicationController private + def set_pack + use_pack 'settings' + end + def set_filters @filters = current_account.custom_filters end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 713365ea5..1462b94fc 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -8,6 +8,7 @@ class FollowerAccountsController < ApplicationController def index respond_to do |format| format.html do + use_pack 'public' mark_cacheable! unless user_signed_in? next if @account.user_hides_network? @@ -43,22 +44,22 @@ class FollowerAccountsController < ApplicationController end def collection_presenter + options = { type: :ordered } + options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count if params[:page].present? ActivityPub::CollectionPresenter.new( id: account_followers_url(@account, page: params.fetch(:page, 1)), - type: :ordered, - size: @account.followers_count, items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, part_of: account_followers_url(@account), next: page_url(follows.next_page), - prev: page_url(follows.prev_page) + prev: page_url(follows.prev_page), + **options ) else ActivityPub::CollectionPresenter.new( id: account_followers_url(@account), - type: :ordered, - size: @account.followers_count, - first: page_url(1) + first: page_url(1), + **options ) end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 1bfd901cf..181f85221 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -8,6 +8,7 @@ class FollowingAccountsController < ApplicationController def index respond_to do |format| format.html do + use_pack 'public' mark_cacheable! unless user_signed_in? next if @account.user_hides_network? diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index d1bd0601e..06ca03e34 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -2,6 +2,8 @@ class HomeController < ApplicationController before_action :authenticate_user! + + before_action :set_pack before_action :set_referrer_policy_header before_action :set_initial_state_json @@ -39,6 +41,10 @@ class HomeController < ApplicationController redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path) end + def set_pack + use_pack 'home' + end + def set_initial_state_json serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) @initial_state_json = serializable_resource.to_json diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index fdb3a0962..52cddc404 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,6 +6,7 @@ class InvitesController < ApplicationController layout 'admin' before_action :authenticate_user! + before_action :set_pack before_action :set_body_classes def index @@ -38,6 +39,10 @@ class InvitesController < ApplicationController private + def set_pack + use_pack 'settings' + end + def invites Invite.where(user: current_user).order(id: :desc) end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index cebbdc4d0..f6f5d1ecc 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -5,6 +5,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController before_action :store_current_location before_action :authenticate_resource_owner! + before_action :set_pack include Localized @@ -14,6 +15,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController store_location_for(:user, request.url) end + def set_pack + use_pack 'auth' + end + def render_success if skip_authorization? || (matching_token? && !truthy_param?('force_login')) redirect_or_render authorize_response diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index f3d235366..4e45445df 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! + before_action :set_pack before_action :set_body_classes include Localized @@ -23,4 +24,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio def store_current_location store_location_for(:user, request.url) end + + def set_pack + use_pack 'settings' + end end diff --git a/app/controllers/public_timelines_controller.rb b/app/controllers/public_timelines_controller.rb index 53d4472d8..c5fe789f4 100644 --- a/app/controllers/public_timelines_controller.rb +++ b/app/controllers/public_timelines_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class PublicTimelinesController < ApplicationController + before_action :set_pack layout 'public' before_action :check_enabled @@ -31,4 +32,8 @@ class PublicTimelinesController < ApplicationController def set_instance_presenter @instance_presenter = InstancePresenter.new end + + def set_pack + use_pack 'about' + end end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index e6705c327..c87a0cf13 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -5,6 +5,7 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show + before_action :set_pack before_action :set_body_classes helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -101,4 +102,8 @@ class RelationshipsController < ApplicationController def set_body_classes @body_classes = 'admin' end + + def set_pack + use_pack 'admin' + end end diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 8ba331cd1..17bc1940a 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -4,6 +4,7 @@ class RemoteFollowController < ApplicationController layout 'modal' before_action :set_account + before_action :set_pack before_action :gone, if: :suspended_account? before_action :set_body_classes @@ -32,6 +33,10 @@ class RemoteFollowController < ApplicationController { acct: session[:remote_follow] } end + def set_pack + use_pack 'modal' + end + def set_account @account = Account.find_local!(params[:account_username]) end diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index cc6993c52..d7197d434 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -8,6 +8,7 @@ class RemoteInteractionController < ApplicationController before_action :set_interaction_type before_action :set_status before_action :set_body_classes + before_action :set_pack def new @remote_follow = RemoteFollow.new(session_params) @@ -47,6 +48,10 @@ class RemoteInteractionController < ApplicationController @hide_header = true end + def set_pack + use_pack 'modal' + end + def set_interaction_type @interaction_type = %w(reply reblog favourite).include?(params[:type]) ? params[:type] : 'reply' end diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index ed3f82a8e..d3ac268d8 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class Settings::ApplicationsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :set_application, only: [:show, :update, :destroy, :regenerate] before_action :prepare_scopes, only: [:create, :update] diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 9bb14afa2..34ef16568 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true class Settings::BaseController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_pack before_action :set_body_classes - private + def set_pack + use_pack 'settings' + end def set_body_classes @body_classes = 'admin' diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index dd19aadf6..4c1121471 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true class Settings::DeletesController < Settings::BaseController - layout 'admin' - before_action :check_enabled_deletion - before_action :authenticate_user! + prepend_before_action :check_enabled_deletion def show @confirmation = Form::DeleteConfirmation.new diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 3012fbf77..7f76668d5 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -3,10 +3,6 @@ class Settings::ExportsController < Settings::BaseController include Authorization - layout 'admin' - - before_action :authenticate_user! - def show @export = Export.new(current_account) @backups = current_user.backups diff --git a/app/controllers/settings/flavours_controller.rb b/app/controllers/settings/flavours_controller.rb new file mode 100644 index 000000000..634387715 --- /dev/null +++ b/app/controllers/settings/flavours_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Settings::FlavoursController < Settings::BaseController + def index + redirect_to action: 'show', flavour: current_flavour + end + + def show + unless Themes.instance.flavours.include?(params[:flavour]) || (params[:flavour] == current_flavour) + redirect_to action: 'show', flavour: current_flavour + end + + @listing = Themes.instance.flavours + @selected = params[:flavour] + end + + def update + user_settings.update(user_settings_params) + redirect_to action: 'show', flavour: params[:flavour] + end + + private + + def user_settings + UserSettingsDecorator.new(current_user) + end + + def user_settings_params + { setting_flavour: params.require(:flavour), + setting_skin: params.dig(:user, :setting_skin) }.with_indifferent_access + end +end diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb index e22b4d9be..4d0938545 100644 --- a/app/controllers/settings/identity_proofs_controller.rb +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -5,6 +5,7 @@ class Settings::IdentityProofsController < Settings::BaseController before_action :authenticate_user! before_action :check_required_params, only: :new + before_action :check_enabled, only: :new def index @proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc) @@ -41,6 +42,10 @@ class Settings::IdentityProofsController < Settings::BaseController private + def check_enabled + not_found unless Setting.enable_keybase + end + def check_required_params redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? } end diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 38f2e39c1..dbd136ebe 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class Settings::ImportsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :set_account def show diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 59eb48779..89b3f7246 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class Settings::MigrationsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! - def show @migration = Form::Migration.new(account: current_account.moved_to_account) end diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb index b2ce83e42..7d7e237fb 100644 --- a/app/controllers/settings/notifications_controller.rb +++ b/app/controllers/settings/notifications_controller.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class Settings::NotificationsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! - def show; end def update diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 5afdf0eec..eb7a0eb4a 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class Settings::PreferencesController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! - def show; end def update @@ -38,6 +34,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_default_language, :setting_unfollow_modal, :setting_boost_modal, + :setting_favourite_modal, :setting_delete_modal, :setting_auto_play_gif, :setting_display_media, @@ -45,8 +42,8 @@ class Settings::PreferencesController < Settings::BaseController :setting_reduce_motion, :setting_system_font_ui, :setting_noindex, - :setting_theme, :setting_hide_network, + :setting_hide_followers_count, :setting_aggregate_reblogs, :setting_show_application, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 8b640cdca..76d599f08 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -3,9 +3,6 @@ class Settings::ProfilesController < Settings::BaseController include ObfuscateFilename - layout 'admin' - - before_action :authenticate_user! before_action :set_account obfuscate_filename [:account, :avatar] diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb index 84ebb21f2..d74db6000 100644 --- a/app/controllers/settings/sessions_controller.rb +++ b/app/controllers/settings/sessions_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class Settings::SessionsController < Settings::BaseController +# Intentionally does not inherit from BaseController +class Settings::SessionsController < ApplicationController before_action :authenticate_user! before_action :set_session, only: :destroy diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index d87117a50..8518c61ee 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -3,9 +3,6 @@ module Settings module TwoFactorAuthentication class ConfirmationsController < BaseController - layout 'admin' - - before_action :authenticate_user! before_action :ensure_otp_secret def new diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb index c78166c65..94d1567f3 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -3,10 +3,6 @@ module Settings module TwoFactorAuthentication class RecoveryCodesController < BaseController - layout 'admin' - - before_action :authenticate_user! - def create @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb index e12c43074..8c7737e9d 100644 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ b/app/controllers/settings/two_factor_authentications_controller.rb @@ -2,9 +2,6 @@ module Settings class TwoFactorAuthenticationsController < BaseController - layout 'admin' - - before_action :authenticate_user! before_action :verify_otp_required, only: [:create] def show diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index af605b98f..ada4eec54 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -4,6 +4,7 @@ class SharesController < ApplicationController layout 'modal' before_action :authenticate_user! + before_action :set_pack before_action :set_body_classes def show @@ -26,6 +27,10 @@ class SharesController < ApplicationController } end + def set_pack + use_pack 'share' + end + def set_body_classes @body_classes = 'modal-layout compose-standalone' end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index e60646ba3..28eebda28 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -27,6 +27,8 @@ class StatusesController < ApplicationController def show respond_to do |format| format.html do + use_pack 'public' + unless user_signed_in? skip_session! expires_in 10.seconds, public: true @@ -59,6 +61,7 @@ class StatusesController < ApplicationController end def embed + use_pack 'embed' raise ActiveRecord::RecordNotFound if @status.hidden? skip_session! diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index b75cefa74..1e16c5157 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -15,6 +15,8 @@ class StreamEntriesController < ApplicationController def show respond_to do |format| format.html do + use_pack 'public' + unless user_signed_in? skip_session! expires_in 5.minutes, public: true @@ -58,7 +60,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/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 66b184901..5cb048c1a 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -13,6 +13,7 @@ class TagsController < ApplicationController respond_to do |format| format.html do + use_pack 'about' @initial_state_json = ActiveModelSerializers::SerializableResource.new( InitialStatePresenter.new(settings: {}, token: current_session&.token), serializer: InitialStateSerializer diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb index eb41e586f..c78683a8d 100644 --- a/app/controllers/well_known/keybase_proof_config_controller.rb +++ b/app/controllers/well_known/keybase_proof_config_controller.rb @@ -2,8 +2,16 @@ module WellKnown class KeybaseProofConfigController < ActionController::Base + before_action :check_enabled + def show render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer end + + private + + def check_enabled + head 404 unless Setting.enable_keybase + end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9d113263d..7ae1e5d0b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -95,7 +95,8 @@ module ApplicationHelper def body_classes output = (@body_classes || '').split(' ') - output << "theme-#{current_theme.parameterize}" + output << "flavour-#{current_flavour.parameterize}" + output << "skin-#{current_skin.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') output << 'rtl' if locale_direction == 'rtl' 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/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index d56efbfb9..6e646ab84 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -60,8 +60,12 @@ module StreamEntriesHelper end end + def hide_followers_count?(account) + Setting.hide_followers_count || account.user&.setting_hide_followers_count + end + def account_description(account) - prepend_str = [ + prepend_stats = [ [ number_to_human(account.statuses_count, strip_insignificant_zeros: true), I18n.t('accounts.posts', count: account.statuses_count), @@ -71,14 +75,16 @@ module StreamEntriesHelper number_to_human(account.following_count, strip_insignificant_zeros: true), I18n.t('accounts.following', count: account.following_count), ].join(' '), + ] - [ + unless hide_followers_count?(account) + prepend_stats << [ number_to_human(account.followers_count, strip_insignificant_zeros: true), I18n.t('accounts.followers', count: account.followers_count), - ].join(' '), - ].join(', ') + ].join(' ') + end - [prepend_str, account.note].join(' · ') + [prepend_stats.join(', '), account.note].join(' · ') end def media_summary(status) diff --git a/app/javascript/packs/admin.js b/app/javascript/core/admin.js index f0c0ee0b7..3f6f187bc 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/core/admin.js @@ -1,3 +1,5 @@ +// This file will be loaded on admin pages, regardless of theme. + import { delegate } from 'rails-ujs'; const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js new file mode 100644 index 000000000..3b038ca40 --- /dev/null +++ b/app/javascript/core/common.js @@ -0,0 +1,5 @@ +// This file will be loaded on all pages, regardless of theme. + +import 'font-awesome/css/font-awesome.css'; + +require.context('../images/', true); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js new file mode 100644 index 000000000..6146e6592 --- /dev/null +++ b/app/javascript/core/embed.js @@ -0,0 +1,23 @@ +// This file will be loaded on embed pages, regardless of theme. + +window.addEventListener('message', e => { + const data = e.data || {}; + + if (!window.parent || data.type !== 'setHeight') { + return; + } + + function setEmbedHeight () { + window.parent.postMessage({ + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0].scrollHeight, + }, '*'); + }; + + if (['interactive', 'complete'].includes(document.readyState)) { + setEmbedHeight(); + } else { + document.addEventListener('DOMContentLoaded', setEmbedHeight); + } +}); diff --git a/app/javascript/core/mailer.js b/app/javascript/core/mailer.js new file mode 100644 index 000000000..baef7e7fb --- /dev/null +++ b/app/javascript/core/mailer.js @@ -0,0 +1 @@ +import 'styles/mailer.scss'; diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js new file mode 100644 index 000000000..4be75647a --- /dev/null +++ b/app/javascript/core/public.js @@ -0,0 +1,66 @@ +// This file will be loaded on public pages, regardless of theme. + +import createHistory from 'history/createBrowserHistory'; +import ready from '../mastodon/ready'; + +const { delegate } = require('rails-ujs'); +const { length } = require('stringz'); + +delegate(document, '.webapp-btn', 'click', ({ target, button }) => { + if (button !== 0) { + return true; + } + window.location.href = target.href; + return false; +}); + +delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => { + const contentEl = target.parentNode.parentNode.querySelector('.e-content'); + + if (contentEl.style.display === 'block') { + contentEl.style.display = 'none'; + target.parentNode.style.marginBottom = 0; + } else { + contentEl.style.display = 'block'; + target.parentNode.style.marginBottom = null; + } + + return false; +}); + +delegate(document, '.modal-button', 'click', e => { + e.preventDefault(); + + let href; + + if (e.target.nodeName !== 'A') { + href = e.target.parentNode.href; + } else { + href = e.target.href; + } + + window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); +}); + +const getProfileAvatarAnimationHandler = (swapTo) => { + //animate avatar gifs on the profile page when moused over + return ({ target }) => { + const swapSrc = target.getAttribute(swapTo); + //only change the img source if autoplay is off and the image src is actually different + if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) { + target.src = swapSrc; + } + }; +}; + +delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); + +delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); + +delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card .card__img img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.src = url; +}); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js new file mode 100644 index 000000000..e0cb944e0 --- /dev/null +++ b/app/javascript/core/settings.js @@ -0,0 +1,74 @@ +// This file will be loaded on settings pages, regardless of theme. + +import escapeTextContentForBrowser from 'escape-html'; +const { delegate } = require('rails-ujs'); +import emojify from '../mastodon/features/emoji/emoji'; + +delegate(document, '#account_display_name', 'input', ({ target }) => { + const name = document.querySelector('.card .display-name strong'); + if (name) { + if (target.value) { + name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); + } else { + name.textContent = document.querySelector('#default_account_display_name').textContent; + } + } +}); + +delegate(document, '#account_avatar', 'change', ({ target }) => { + const avatar = document.querySelector('.card .avatar img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + avatar.src = url; +}); + +delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card .card__img img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.src = url; +}); + +delegate(document, '#account_locked', 'change', ({ target }) => { + const lock = document.querySelector('.card .display-name i'); + + if (target.checked) { + lock.style.display = 'inline'; + } else { + lock.style.display = 'none'; + } +}); + +delegate(document, '.input-copy input', 'click', ({ target }) => { + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +delegate(document, '.input-copy button', 'click', ({ target }) => { + const input = target.parentNode.querySelector('.input-copy__wrapper input'); + + const oldReadOnly = input.readonly; + + input.readonly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + target.parentNode.classList.add('copied'); + + setTimeout(() => { + target.parentNode.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readonly = oldReadOnly; +}); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml new file mode 100644 index 000000000..32787bcab --- /dev/null +++ b/app/javascript/core/theme.yml @@ -0,0 +1,19 @@ +# These packs will be loaded on every appropriate page, regardless of +# theme. +pack: + about: + admin: admin.js + auth: + common: + filename: common.js + stylesheet: true + embed: embed.js + error: + home: + mailer: + filename: mailer.js + stylesheet: true + modal: public.js + public: public.js + settings: settings.js + share: diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js new file mode 100644 index 000000000..b659e4ff3 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -0,0 +1,830 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer'; + +export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; +export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; +export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; + +export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; +export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; +export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; + +export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; +export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; +export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; +export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; +export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; + +export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; +export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; +export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; + +export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; +export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; +export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; +export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; + +export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY'; +export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR'; +export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE'; + +export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET'; + + +export function fetchAccount(id) { + return (dispatch, getState) => { + dispatch(fetchRelationships([id])); + + if (getState().getIn(['accounts', id], null) !== null) { + return; + } + + dispatch(fetchAccountRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}`).then(response => { + dispatch(importFetchedAccount(response.data)); + }).then(() => { + dispatch(fetchAccountSuccess()); + }).catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; +}; + +export function fetchAccountRequest(id) { + return { + type: ACCOUNT_FETCH_REQUEST, + id, + }; +}; + +export function fetchAccountSuccess() { + return { + type: ACCOUNT_FETCH_SUCCESS, + }; +}; + +export function fetchAccountFail(id, error) { + return { + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true, + }; +}; + +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`, { reblogs }).then(response => { + dispatch(followAccountSuccess(response.data, alreadyFollowing)); + }).catch(error => { + dispatch(followAccountFail(error)); + }); + }; +}; + +export function unfollowAccount(id) { + return (dispatch, getState) => { + dispatch(unfollowAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { + dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(unfollowAccountFail(error)); + }); + }; +}; + +export function followAccountRequest(id) { + return { + type: ACCOUNT_FOLLOW_REQUEST, + id, + }; +}; + +export function followAccountSuccess(relationship, alreadyFollowing) { + return { + type: ACCOUNT_FOLLOW_SUCCESS, + relationship, + alreadyFollowing, + }; +}; + +export function followAccountFail(error) { + return { + type: ACCOUNT_FOLLOW_FAIL, + error, + }; +}; + +export function unfollowAccountRequest(id) { + return { + type: ACCOUNT_UNFOLLOW_REQUEST, + id, + }; +}; + +export function unfollowAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_UNFOLLOW_SUCCESS, + relationship, + statuses, + }; +}; + +export function unfollowAccountFail(error) { + return { + type: ACCOUNT_UNFOLLOW_FAIL, + error, + }; +}; + +export function blockAccount(id) { + return (dispatch, getState) => { + dispatch(blockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(blockAccountFail(id, error)); + }); + }; +}; + +export function unblockAccount(id) { + return (dispatch, getState) => { + dispatch(unblockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { + dispatch(unblockAccountSuccess(response.data)); + }).catch(error => { + dispatch(unblockAccountFail(id, error)); + }); + }; +}; + +export function blockAccountRequest(id) { + return { + type: ACCOUNT_BLOCK_REQUEST, + id, + }; +}; + +export function blockAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_BLOCK_SUCCESS, + relationship, + statuses, + }; +}; + +export function blockAccountFail(error) { + return { + type: ACCOUNT_BLOCK_FAIL, + error, + }; +}; + +export function unblockAccountRequest(id) { + return { + type: ACCOUNT_UNBLOCK_REQUEST, + id, + }; +}; + +export function unblockAccountSuccess(relationship) { + return { + type: ACCOUNT_UNBLOCK_SUCCESS, + relationship, + }; +}; + +export function unblockAccountFail(error) { + return { + type: ACCOUNT_UNBLOCK_FAIL, + error, + }; +}; + + +export function muteAccount(id, notifications) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(muteAccountFail(id, error)); + }); + }; +}; + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess(response.data)); + }).catch(error => { + dispatch(unmuteAccountFail(id, error)); + }); + }; +}; + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id, + }; +}; + +export function muteAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses, + }; +}; + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error, + }; +}; + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id, + }; +}; + +export function unmuteAccountSuccess(relationship) { + return { + type: ACCOUNT_UNMUTE_SUCCESS, + relationship, + }; +}; + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error, + }; +}; + + +export function fetchFollowers(id) { + return (dispatch, getState) => { + dispatch(fetchFollowersRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; +}; + +export function fetchFollowersRequest(id) { + return { + type: FOLLOWERS_FETCH_REQUEST, + id, + }; +}; + +export function fetchFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next, + }; +}; + +export function fetchFollowersFail(id, error) { + return { + type: FOLLOWERS_FETCH_FAIL, + id, + error, + }; +}; + +export function expandFollowers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'followers', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; +}; + +export function expandFollowersRequest(id) { + return { + type: FOLLOWERS_EXPAND_REQUEST, + id, + }; +}; + +export function expandFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +}; + +export function expandFollowersFail(id, error) { + return { + type: FOLLOWERS_EXPAND_FAIL, + id, + error, + }; +}; + +export function fetchFollowing(id) { + return (dispatch, getState) => { + dispatch(fetchFollowingRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; +}; + +export function fetchFollowingRequest(id) { + return { + type: FOLLOWING_FETCH_REQUEST, + id, + }; +}; + +export function fetchFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next, + }; +}; + +export function fetchFollowingFail(id, error) { + return { + type: FOLLOWING_FETCH_FAIL, + id, + error, + }; +}; + +export function expandFollowing(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'following', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowingRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; +}; + +export function expandFollowingRequest(id) { + return { + type: FOLLOWING_EXPAND_REQUEST, + id, + }; +}; + +export function expandFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next, + }; +}; + +export function expandFollowingFail(id, error) { + return { + type: FOLLOWING_EXPAND_FAIL, + id, + error, + }; +}; + +export function fetchRelationships(accountIds) { + return (dispatch, getState) => { + const loadedRelationships = getState().get('relationships'); + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); + }; +}; + +export function fetchRelationshipsRequest(ids) { + return { + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, + }; +}; + +export function fetchRelationshipsSuccess(relationships) { + return { + type: RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, + }; +}; + +export function fetchRelationshipsFail(error) { + return { + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, + }; +}; + +export function fetchFollowRequests() { + return (dispatch, getState) => { + dispatch(fetchFollowRequestsRequest()); + + api(getState).get('/api/v1/follow_requests').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(fetchFollowRequestsFail(error))); + }; +}; + +export function fetchFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_FETCH_REQUEST, + }; +}; + +export function fetchFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_FETCH_FAIL, + error, + }; +}; + +export function expandFollowRequests() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'follow_requests', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(expandFollowRequestsFail(error))); + }; +}; + +export function expandFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_EXPAND_REQUEST, + }; +}; + +export function expandFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next, + }; +}; + +export function expandFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error, + }; +}; + +export function authorizeFollowRequest(id) { + return (dispatch, getState) => { + dispatch(authorizeFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(() => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; +}; + +export function authorizeFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id, + }; +}; + +export function authorizeFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id, + }; +}; + +export function authorizeFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error, + }; +}; + + +export function rejectFollowRequest(id) { + return (dispatch, getState) => { + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(() => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; +}; + +export function rejectFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_REJECT_REQUEST, + id, + }; +}; + +export function rejectFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id, + }; +}; + +export function rejectFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error, + }; +}; + +export function pinAccount(id) { + return (dispatch, getState) => { + dispatch(pinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + dispatch(pinAccountSuccess(response.data)); + }).catch(error => { + dispatch(pinAccountFail(error)); + }); + }; +}; + +export function unpinAccount(id) { + return (dispatch, getState) => { + dispatch(unpinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + dispatch(unpinAccountSuccess(response.data)); + }).catch(error => { + dispatch(unpinAccountFail(error)); + }); + }; +}; + +export function pinAccountRequest(id) { + return { + type: ACCOUNT_PIN_REQUEST, + id, + }; +}; + +export function pinAccountSuccess(relationship) { + return { + type: ACCOUNT_PIN_SUCCESS, + relationship, + }; +}; + +export function pinAccountFail(error) { + return { + type: ACCOUNT_PIN_FAIL, + error, + }; +}; + +export function unpinAccountRequest(id) { + return { + type: ACCOUNT_UNPIN_REQUEST, + id, + }; +}; + +export function unpinAccountSuccess(relationship) { + return { + type: ACCOUNT_UNPIN_SUCCESS, + relationship, + }; +}; + +export function unpinAccountFail(error) { + return { + type: ACCOUNT_UNPIN_FAIL, + error, + }; +}; + +export function fetchPinnedAccounts() { + return (dispatch, getState) => { + dispatch(fetchPinnedAccountsRequest()); + + api(getState).get(`/api/v1/endorsements`, { params: { limit: 0 } }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuccess(response.data)); + }).catch(err => dispatch(fetchPinnedAccountsFail(err))); + }; +}; + +export function fetchPinnedAccountsRequest() { + return { + type: PINNED_ACCOUNTS_FETCH_REQUEST, + }; +}; + +export function fetchPinnedAccountsSuccess(accounts, next) { + return { + type: PINNED_ACCOUNTS_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchPinnedAccountsFail(error) { + return { + type: PINNED_ACCOUNTS_FETCH_FAIL, + error, + }; +}; + +export function fetchPinnedAccountsSuggestions(q) { + return (dispatch, getState) => { + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuggestionsReady(q, response.data)); + }); + }; +}; + +export function fetchPinnedAccountsSuggestionsReady(query, accounts) { + return { + type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY, + query, + accounts, + }; +}; + +export function clearPinnedAccountsSuggestions() { + return { + type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, + }; +}; + +export function changePinnedAccountsSuggestions(value) { + return { + type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, + value, + } +}; + +export function resetPinnedAccountsEditor() { + return { + type: PINNED_ACCOUNTS_EDITOR_RESET, + }; +}; + diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js new file mode 100644 index 000000000..b2c7ab76a --- /dev/null +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -0,0 +1,54 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, +}); + +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; + +export function dismissAlert(alert) { + return { + type: ALERT_DISMISS, + alert, + }; +}; + +export function clearAlert() { + return { + type: ALERT_CLEAR, + }; +}; + +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { + return { + type: ALERT_SHOW, + title, + message, + }; +}; + +export function showAlertForError(error) { + if (error.response) { + const { data, status, statusText } = error.response; + + if (status === 404 || status === 410) { + // Skip these errors as they are reflected in the UI + return {}; + } + + let message = statusText; + let title = `${status}`; + + if (data.error) { + message = data.error; + } + + return showAlert(title, message); + } else { + console.error(error); + return showAlert(); + } +} diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js new file mode 100644 index 000000000..498ce519f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/blocks.js @@ -0,0 +1,85 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; +export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; +export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; + +export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; +export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; +export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; + +export function fetchBlocks() { + return (dispatch, getState) => { + dispatch(fetchBlocksRequest()); + + api(getState).get('/api/v1/blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchBlocksFail(error))); + }; +}; + +export function fetchBlocksRequest() { + return { + type: BLOCKS_FETCH_REQUEST, + }; +}; + +export function fetchBlocksSuccess(accounts, next) { + return { + type: BLOCKS_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchBlocksFail(error) { + return { + type: BLOCKS_FETCH_FAIL, + error, + }; +}; + +export function expandBlocks() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'blocks', 'next']); + + if (url === null) { + return; + } + + dispatch(expandBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandBlocksFail(error))); + }; +}; + +export function expandBlocksRequest() { + return { + type: BLOCKS_EXPAND_REQUEST, + }; +}; + +export function expandBlocksSuccess(accounts, next) { + return { + type: BLOCKS_EXPAND_SUCCESS, + accounts, + next, + }; +}; + +export function expandBlocksFail(error) { + return { + type: BLOCKS_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/bookmarks.js b/app/javascript/flavours/glitch/actions/bookmarks.js new file mode 100644 index 000000000..83dbf5407 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/bookmarks.js @@ -0,0 +1,90 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { importFetchedStatuses } from './importer'; + +export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; +export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; +export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; + +export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; +export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; +export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; + +export function fetchBookmarkedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(fetchBookmarkedStatusesRequest()); + + api(getState).get('/api/v1/bookmarks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchBookmarkedStatusesFail(error)); + }); + }; +}; + +export function fetchBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_FETCH_FAIL, + error, + }; +}; + +export function expandBookmarkedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(expandBookmarkedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkedStatusesFail(error)); + }); + }; +}; + +export function expandBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_EXPAND_REQUEST, + }; +}; + +export function expandBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +}; + +export function expandBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/bundles.js b/app/javascript/flavours/glitch/actions/bundles.js new file mode 100644 index 000000000..ecc9c8f7d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/bundles.js @@ -0,0 +1,25 @@ +export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; +export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; +export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; + +export function fetchBundleRequest(skipLoading) { + return { + type: BUNDLE_FETCH_REQUEST, + skipLoading, + }; +} + +export function fetchBundleSuccess(skipLoading) { + return { + type: BUNDLE_FETCH_SUCCESS, + skipLoading, + }; +} + +export function fetchBundleFail(error, skipLoading) { + return { + type: BUNDLE_FETCH_FAIL, + error, + skipLoading, + }; +} diff --git a/app/javascript/flavours/glitch/actions/columns.js b/app/javascript/flavours/glitch/actions/columns.js new file mode 100644 index 000000000..9b87415fb --- /dev/null +++ b/app/javascript/flavours/glitch/actions/columns.js @@ -0,0 +1,54 @@ +import { saveSettings } from './settings'; + +export const COLUMN_ADD = 'COLUMN_ADD'; +export const COLUMN_REMOVE = 'COLUMN_REMOVE'; +export const COLUMN_MOVE = 'COLUMN_MOVE'; +export const COLUMN_PARAMS_CHANGE = 'COLUMN_PARAMS_CHANGE'; + +export function addColumn(id, params) { + return dispatch => { + dispatch({ + type: COLUMN_ADD, + id, + params, + }); + + dispatch(saveSettings()); + }; +}; + +export function removeColumn(uuid) { + return dispatch => { + dispatch({ + type: COLUMN_REMOVE, + uuid, + }); + + dispatch(saveSettings()); + }; +}; + +export function moveColumn(uuid, direction) { + return dispatch => { + dispatch({ + type: COLUMN_MOVE, + uuid, + direction, + }); + + dispatch(saveSettings()); + }; +}; + +export function changeColumnParams(uuid, path, value) { + return dispatch => { + dispatch({ + type: COLUMN_PARAMS_CHANGE, + uuid, + path, + value, + }); + + dispatch(saveSettings()); + }; +} diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js new file mode 100644 index 000000000..f117ce771 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -0,0 +1,568 @@ +import api from 'flavours/glitch/util/api'; +import { CancelToken, isCancel } from 'axios'; +import { throttle } from 'lodash'; +import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; +import { useEmoji } from './emojis'; +import { tagHistory } from 'flavours/glitch/util/settings'; +import { recoverHashtags } from 'flavours/glitch/util/hashtag'; +import resizeImage from 'flavours/glitch/util/resize_image'; +import { importFetchedAccounts } from './importer'; +import { updateTimeline } from './timelines'; +import { showAlertForError } from './alerts'; +import { showAlert } from './alerts'; +import { defineMessages } from 'react-intl'; + +let cancelFetchComposeSuggestionsAccounts; + +export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; +export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND'; +export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; +export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; +export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; +export const COMPOSE_REPLY = 'COMPOSE_REPLY'; +export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; +export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_RESET = 'COMPOSE_RESET'; +export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; +export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; +export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; +export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; +export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; + +export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; +export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; + +export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; + +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'; +export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; + +export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + +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 const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; +export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; +export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; +export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; +export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; +export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; + +const messages = defineMessages({ + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, +}); + +export function changeCompose(text) { + return { + type: COMPOSE_CHANGE, + text: text, + }; +}; + +export function cycleElefriendCompose() { + return { + type: COMPOSE_CYCLE_ELEFRIEND, + }; +}; + +export function replyCompose(status, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_REPLY, + status: status, + }); + + if (router && !getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function cancelReplyCompose() { + return { + type: COMPOSE_REPLY_CANCEL, + }; +}; + +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + +export function mentionCompose(account, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_MENTION, + account: account, + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function directCompose(account, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function submitCompose(routerHistory) { + return function (dispatch, getState) { + let status = getState().getIn(['compose', 'text'], ''); + let media = getState().getIn(['compose', 'media_attachments']); + let spoilerText = getState().getIn(['compose', 'spoiler_text'], ''); + + if ((!status || !status.length) && media.size === 0) { + 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), + media_ids: media.map(item => item.get('id')), + sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), + spoiler_text: spoilerText, + visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), + }, { + headers: { + 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), + }, + }).then(function (response) { + if (routerHistory && routerHistory.location.pathname === '/statuses/new' + && window.history.state + && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) { + routerHistory.goBack(); + } + + dispatch(insertIntoTagHistory(response.data.tags, status)); + dispatch(submitComposeSuccess({ ...response.data })); + + // If the response has no data then we can't do anything else. + if (!response.data) { + return; + } + + // To make the app more responsive, immediately get the status into the columns + + const insertIfOnline = (timelineId) => { + const timeline = getState().getIn(['timelines', timelineId]); + + if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) { + dispatch(updateTimeline(timelineId, { ...response.data })); + } + }; + + insertIfOnline('home'); + + if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + insertIfOnline('community'); + insertIfOnline('public'); + } else if (response.data.visibility === 'direct') { + insertIfOnline('direct'); + } + }).catch(function (error) { + dispatch(submitComposeFail(error)); + }); + }; +}; + +export function submitComposeRequest() { + return { + type: COMPOSE_SUBMIT_REQUEST, + }; +}; + +export function submitComposeSuccess(status) { + return { + type: COMPOSE_SUBMIT_SUCCESS, + status: status, + }; +}; + +export function submitComposeFail(error) { + return { + type: COMPOSE_SUBMIT_FAIL, + error: error, + }; +}; + +export function doodleSet(options) { + return { + type: COMPOSE_DOODLE_SET, + options: options, + }; +}; + +export function uploadCompose(files) { + return function (dispatch, getState) { + const uploadLimit = 4; + const media = getState().getIn(['compose', 'media_attachments']); + const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); + + if (files.length + media.size > uploadLimit) { + dispatch(showAlert(undefined, messages.uploadErrorLimit)); + return; + } + + if (getState().getIn(['compose', 'poll'])) { + dispatch(showAlert(undefined, messages.uploadErrorPoll)); + return; + } + + dispatch(uploadComposeRequest()); + + for (const [i, f] of Array.from(files).entries()) { + if (media.size + i > 3) break; + + resizeImage(f).then(file => { + const data = new FormData(); + data.append('file', file); + // Account for disparity in size of original image and resized data + total += file.size - f.size; + + return api(getState).post('/api/v1/media', data, { + onUploadProgress: function({ loaded }){ + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }, + }).then(({ data }) => dispatch(uploadComposeSuccess(data))); + }).catch(error => dispatch(uploadComposeFail(error))); + }; + }; +}; + +export function changeUploadCompose(id, params) { + return (dispatch, getState) => { + dispatch(changeUploadComposeRequest()); + + api(getState).put(`/api/v1/media/${id}`, params).then(response => { + dispatch(changeUploadComposeSuccess(response.data)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + }; +}; + +export function changeUploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, + }; +}; +export function changeUploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + skipLoading: true, + }; +}; + +export function changeUploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_CHANGE_FAIL, + error: error, + skipLoading: true, + }; +}; + +export function uploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_REQUEST, + skipLoading: true, + }; +}; + +export function uploadComposeProgress(loaded, total) { + return { + type: COMPOSE_UPLOAD_PROGRESS, + loaded: loaded, + total: total, + }; +}; + +export function uploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + skipLoading: true, + }; +}; + +export function uploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_FAIL, + error: error, + skipLoading: true, + }; +}; + +export function undoUploadCompose(media_id) { + return { + type: COMPOSE_UPLOAD_UNDO, + media_id: media_id, + }; +}; + +export function clearComposeSuggestions() { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } + return { + type: COMPOSE_SUGGESTIONS_CLEAR, + }; +}; + +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } + api(getState).get('/api/v1/accounts/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsAccounts = cancel; + }), + params: { + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } + }); +}, 200, { leading: true, trailing: true }); + +const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); + dispatch(readyComposeSuggestionsEmojis(token, results)); +}; + +const fetchComposeSuggestionsTags = (dispatch, getState, token) => { + dispatch(updateSuggestionTags(token)); +}; + +export function fetchComposeSuggestions(token) { + return (dispatch, getState) => { + switch (token[0]) { + case ':': + fetchComposeSuggestionsEmojis(dispatch, getState, token); + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, token); + break; + default: + fetchComposeSuggestionsAccounts(dispatch, getState, token); + break; + } + }; +}; + +export function readyComposeSuggestionsEmojis(token, emojis) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, + }; +}; + +export function readyComposeSuggestionsAccounts(token, accounts) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + accounts, + }; +}; + +export function selectComposeSuggestion(position, token, suggestion, path) { + return (dispatch, getState) => { + let completion; + if (typeof suggestion === 'object' && suggestion.id) { + dispatch(useEmoji(suggestion)); + completion = suggestion.native || suggestion.colons; + } else if (suggestion[0] === '#') { + completion = suggestion; + } else { + completion = '@' + getState().getIn(['accounts', suggestion, 'acct']); + } + + dispatch({ + type: COMPOSE_SUGGESTION_SELECT, + position, + token, + completion, + path, + }); + }; +}; + +export function updateSuggestionTags(token) { + return { + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + token, + }; +} + +export function updateTagHistory(tags) { + return { + type: COMPOSE_TAG_HISTORY_UPDATE, + tags, + }; +} + +export function hydrateCompose() { + return (dispatch, getState) => { + const me = getState().getIn(['meta', 'me']); + const history = tagHistory.get(me); + + if (history !== null) { + dispatch(updateTagHistory(history)); + } + }; +} + +function insertIntoTagHistory(recognizedTags, text) { + return (dispatch, getState) => { + const state = getState(); + const oldHistory = state.getIn(['compose', 'tagHistory']); + const me = state.getIn(['meta', 'me']); + const names = recoverHashtags(recognizedTags, text); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me, newHistory); + dispatch(updateTagHistory(newHistory)); + }; +} + +export function mountCompose() { + return { + type: COMPOSE_MOUNT, + }; +}; + +export function unmountCompose() { + return { + type: COMPOSE_UNMOUNT, + }; +}; + +export function changeComposeAdvancedOption(option, value) { + return { + option, + type: COMPOSE_ADVANCED_OPTIONS_CHANGE, + value, + }; +} + +export function changeComposeSensitivity() { + return { + type: COMPOSE_SENSITIVITY_CHANGE, + }; +}; + +export function changeComposeSpoilerness() { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text, + }; +}; + +export function changeComposeVisibility(value) { + return { + type: COMPOSE_VISIBILITY_CHANGE, + value, + }; +}; + +export function insertEmojiCompose(position, emoji) { + return { + type: COMPOSE_EMOJI_INSERT, + position, + emoji, + }; +}; + +export function addPoll() { + return { + type: COMPOSE_POLL_ADD, + }; +}; + +export function removePoll() { + return { + type: COMPOSE_POLL_REMOVE, + }; +}; + +export function addPollOption(title) { + return { + type: COMPOSE_POLL_OPTION_ADD, + title, + }; +}; + +export function changePollOption(index, title) { + return { + type: COMPOSE_POLL_OPTION_CHANGE, + index, + title, + }; +}; + +export function removePollOption(index) { + return { + type: COMPOSE_POLL_OPTION_REMOVE, + index, + }; +}; + +export function changePollSettings(expiresIn, isMultiple) { + return { + type: COMPOSE_POLL_SETTINGS_CHANGE, + expiresIn, + isMultiple, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/custom_emojis.js b/app/javascript/flavours/glitch/actions/custom_emojis.js new file mode 100644 index 000000000..29ae72edb --- /dev/null +++ b/app/javascript/flavours/glitch/actions/custom_emojis.js @@ -0,0 +1,40 @@ +import api from 'flavours/glitch/util/api'; + +export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; +export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; +export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; + +export function fetchCustomEmojis() { + return (dispatch, getState) => { + dispatch(fetchCustomEmojisRequest()); + + api(getState).get('/api/v1/custom_emojis').then(response => { + dispatch(fetchCustomEmojisSuccess(response.data)); + }).catch(error => { + dispatch(fetchCustomEmojisFail(error)); + }); + }; +}; + +export function fetchCustomEmojisRequest() { + return { + type: CUSTOM_EMOJIS_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchCustomEmojisSuccess(custom_emojis) { + return { + type: CUSTOM_EMOJIS_FETCH_SUCCESS, + custom_emojis, + skipLoading: true, + }; +}; + +export function fetchCustomEmojisFail(error) { + return { + type: CUSTOM_EMOJIS_FETCH_FAIL, + error, + skipLoading: true, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js new file mode 100644 index 000000000..7397f561b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/domain_blocks.js @@ -0,0 +1,165 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; + +export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; +export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; +export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; + +export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; +export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; +export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; + +export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; +export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; +export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; + +export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; +export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; +export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL'; + +export function blockDomain(domain) { + return (dispatch, getState) => { + dispatch(blockDomainRequest(domain)); + + api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { + const at_domain = '@' + domain; + const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); + dispatch(blockDomainSuccess(domain, accounts)); + }).catch(err => { + dispatch(blockDomainFail(domain, err)); + }); + }; +}; + +export function blockDomainRequest(domain) { + return { + type: DOMAIN_BLOCK_REQUEST, + domain, + }; +}; + +export function blockDomainSuccess(domain, accounts) { + return { + type: DOMAIN_BLOCK_SUCCESS, + domain, + accounts, + }; +}; + +export function blockDomainFail(domain, error) { + return { + type: DOMAIN_BLOCK_FAIL, + domain, + error, + }; +}; + +export function unblockDomain(domain) { + return (dispatch, getState) => { + dispatch(unblockDomainRequest(domain)); + + api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { + const at_domain = '@' + domain; + const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); + dispatch(unblockDomainSuccess(domain, accounts)); + }).catch(err => { + dispatch(unblockDomainFail(domain, err)); + }); + }; +}; + +export function unblockDomainRequest(domain) { + return { + type: DOMAIN_UNBLOCK_REQUEST, + domain, + }; +}; + +export function unblockDomainSuccess(domain, accounts) { + return { + type: DOMAIN_UNBLOCK_SUCCESS, + domain, + accounts, + }; +}; + +export function unblockDomainFail(domain, error) { + return { + type: DOMAIN_UNBLOCK_FAIL, + domain, + error, + }; +}; + +export function fetchDomainBlocks() { + return (dispatch, getState) => { + dispatch(fetchDomainBlocksRequest()); + + api(getState).get('/api/v1/domain_blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchDomainBlocksFail(err)); + }); + }; +}; + +export function fetchDomainBlocksRequest() { + return { + type: DOMAIN_BLOCKS_FETCH_REQUEST, + }; +}; + +export function fetchDomainBlocksSuccess(domains, next) { + return { + type: DOMAIN_BLOCKS_FETCH_SUCCESS, + domains, + next, + }; +}; + +export function fetchDomainBlocksFail(error) { + return { + type: DOMAIN_BLOCKS_FETCH_FAIL, + error, + }; +}; + +export function expandDomainBlocks() { + return (dispatch, getState) => { + const url = getState().getIn(['domain_lists', 'blocks', 'next']); + + if (!url) { + return; + } + + dispatch(expandDomainBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(expandDomainBlocksFail(err)); + }); + }; +}; + +export function expandDomainBlocksRequest() { + return { + type: DOMAIN_BLOCKS_EXPAND_REQUEST, + }; +}; + +export function expandDomainBlocksSuccess(domains, next) { + return { + type: DOMAIN_BLOCKS_EXPAND_SUCCESS, + domains, + next, + }; +}; + +export function expandDomainBlocksFail(error) { + return { + type: DOMAIN_BLOCKS_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js new file mode 100644 index 000000000..14f2939c7 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/dropdown_menu.js @@ -0,0 +1,10 @@ +export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; +export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; + +export function openDropdownMenu(id, placement, keyboard) { + return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard }; +} + +export function closeDropdownMenu(id) { + return { type: DROPDOWN_MENU_CLOSE, id }; +} diff --git a/app/javascript/flavours/glitch/actions/emojis.js b/app/javascript/flavours/glitch/actions/emojis.js new file mode 100644 index 000000000..7cd9d4b7b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/emojis.js @@ -0,0 +1,14 @@ +import { saveSettings } from './settings'; + +export const EMOJI_USE = 'EMOJI_USE'; + +export function useEmoji(emoji) { + return dispatch => { + dispatch({ + type: EMOJI_USE, + emoji, + }); + + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/flavours/glitch/actions/favourites.js b/app/javascript/flavours/glitch/actions/favourites.js new file mode 100644 index 000000000..0d8bfb14d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/favourites.js @@ -0,0 +1,93 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { importFetchedStatuses } from './importer'; + +export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +export function fetchFavouritedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) { + return; + } + + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; +}; + +export function fetchFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, + }; +}; + +export function fetchFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_FETCH_FAIL, + error, + skipLoading: true, + }; +}; + +export function expandFavouritedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'favourites', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; +}; + +export function expandFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_EXPAND_REQUEST, + }; +}; + +export function expandFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +}; + +export function expandFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/filters.js b/app/javascript/flavours/glitch/actions/filters.js new file mode 100644 index 000000000..050b30322 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/filters.js @@ -0,0 +1,26 @@ +import api from 'flavours/glitch/util/api'; + +export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; + +export const fetchFilters = () => (dispatch, getState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v1/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); +}; diff --git a/app/javascript/flavours/glitch/actions/height_cache.js b/app/javascript/flavours/glitch/actions/height_cache.js new file mode 100644 index 000000000..4c752993f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/height_cache.js @@ -0,0 +1,17 @@ +export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; +export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; + +export function setHeight (key, id, height) { + return { + type: HEIGHT_CACHE_SET, + key, + id, + height, + }; +}; + +export function clearHeight () { + return { + type: HEIGHT_CACHE_CLEAR, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/identity_proofs.js b/app/javascript/flavours/glitch/actions/identity_proofs.js new file mode 100644 index 000000000..a7241da20 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/identity_proofs.js @@ -0,0 +1,30 @@ +import api from 'flavours/glitch/util/api'; + +export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST'; +export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS'; +export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL'; + +export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => { + dispatch(fetchAccountIdentityProofsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`) + .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err))); +}; + +export const fetchAccountIdentityProofsRequest = id => ({ + type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, + id, +}); + +export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({ + type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, + accountId, + identity_proofs, +}); + +export const fetchAccountIdentityProofsFail = (accountId, err) => ({ + type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, + accountId, + err, +}); diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js new file mode 100644 index 000000000..f4372fb31 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -0,0 +1,90 @@ +import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer'; + +export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; +export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +export const STATUS_IMPORT = 'STATUS_IMPORT'; +export const STATUSES_IMPORT = 'STATUSES_IMPORT'; +export const POLLS_IMPORT = 'POLLS_IMPORT'; + +function pushUnique(array, object) { + if (array.every(element => element.id !== object.id)) { + array.push(object); + } +} + +export function importAccount(account) { + return { type: ACCOUNT_IMPORT, account }; +} + +export function importAccounts(accounts) { + return { type: ACCOUNTS_IMPORT, accounts }; +} + +export function importStatus(status) { + return { type: STATUS_IMPORT, status }; +} + +export function importStatuses(statuses) { + return { type: STATUSES_IMPORT, statuses }; +} + +export function importPolls(polls) { + return { type: POLLS_IMPORT, polls }; +} + +export function importFetchedAccount(account) { + return importFetchedAccounts([account]); +} + +export function importFetchedAccounts(accounts) { + const normalAccounts = []; + + function processAccount(account) { + pushUnique(normalAccounts, normalizeAccount(account)); + + if (account.moved) { + processAccount(account.moved); + } + } + + accounts.forEach(processAccount); + + return importAccounts(normalAccounts); +} + +export function importFetchedStatus(status) { + return importFetchedStatuses([status]); +} + +export function importFetchedStatuses(statuses) { + return (dispatch, getState) => { + const accounts = []; + const normalStatuses = []; + const polls = []; + + function processStatus(status) { + pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); + pushUnique(accounts, status.account); + + if (status.reblog && status.reblog.id) { + processStatus(status.reblog); + } + + if (status.poll && status.poll.id) { + pushUnique(polls, normalizePoll(status.poll)); + } + } + + statuses.forEach(processStatus); + + dispatch(importPolls(polls)); + dispatch(importFetchedAccounts(accounts)); + dispatch(importStatuses(normalStatuses)); + }; +} + +export function importFetchedPoll(poll) { + return dispatch => { + dispatch(importPolls([normalizePoll(poll)])); + }; +} diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js new file mode 100644 index 000000000..a8c3fe16a --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -0,0 +1,80 @@ +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'flavours/glitch/util/emoji'; +import { unescapeHTML } from 'flavours/glitch/util/html'; +import { expandSpoilers } from 'flavours/glitch/util/initial_state'; + +const domParser = new DOMParser(); + +const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; +}, {}); + +export function normalizeAccount(account) { + account = { ...account }; + + const emojiMap = makeEmojiMap(account); + const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; + + account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); + account.note_emojified = emojify(account.note, emojiMap); + + if (account.fields) { + account.fields = account.fields.map(pair => ({ + ...pair, + name_emojified: emojify(escapeTextContentForBrowser(pair.name)), + value_emojified: emojify(pair.value, emojiMap), + value_plain: unescapeHTML(pair.value), + })); + } + + if (account.moved) { + account.moved = account.moved.id; + } + + return account; +} + +export function normalizeStatus(status, normalOldStatus) { + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + normalStatus.reblog = status.reblog.id; + } + + if (status.poll && status.poll.id) { + normalStatus.poll = status.poll.id; + } + + // Only calculate these values when status first encountered + // Otherwise keep the ones already in the reducer + if (normalOldStatus) { + normalStatus.search_index = normalOldStatus.get('search_index'); + normalStatus.contentHtml = normalOldStatus.get('contentHtml'); + normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); + } else { + const spoilerText = normalStatus.spoiler_text || ''; + const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const emojiMap = makeEmojiMap(normalStatus); + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); + } + + return normalStatus; +} + +export function normalizePoll(poll) { + const normalPoll = { ...poll }; + + const emojiMap = makeEmojiMap(normalPoll); + + normalPoll.options = poll.options.map(option => ({ + ...option, + title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), + })); + + return normalPoll; +} diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js new file mode 100644 index 000000000..4407f8b6e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -0,0 +1,394 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts, importFetchedStatus } from './importer'; + +export const REBLOG_REQUEST = 'REBLOG_REQUEST'; +export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; +export const REBLOG_FAIL = 'REBLOG_FAIL'; + +export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; +export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; +export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; + +export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; +export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; +export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; + +export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; +export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; +export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; + +export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; +export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; +export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; + +export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; +export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; +export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; + +export const PIN_REQUEST = 'PIN_REQUEST'; +export const PIN_SUCCESS = 'PIN_SUCCESS'; +export const PIN_FAIL = 'PIN_FAIL'; + +export const UNPIN_REQUEST = 'UNPIN_REQUEST'; +export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +export const UNPIN_FAIL = 'UNPIN_FAIL'; + +export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; +export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; +export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + +export function reblog(status) { + return function (dispatch, getState) { + dispatch(reblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(importFetchedStatus(response.data.reblog)); + dispatch(reblogSuccess(status)); + }).catch(function (error) { + dispatch(reblogFail(status, error)); + }); + }; +}; + +export function unreblog(status) { + return (dispatch, getState) => { + dispatch(unreblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unreblogSuccess(status)); + }).catch(error => { + dispatch(unreblogFail(status, error)); + }); + }; +}; + +export function reblogRequest(status) { + return { + type: REBLOG_REQUEST, + status: status, + }; +}; + +export function reblogSuccess(status) { + return { + type: REBLOG_SUCCESS, + status: status, + }; +}; + +export function reblogFail(status, error) { + return { + type: REBLOG_FAIL, + status: status, + error: error, + }; +}; + +export function unreblogRequest(status) { + return { + type: UNREBLOG_REQUEST, + status: status, + }; +}; + +export function unreblogSuccess(status) { + return { + type: UNREBLOG_SUCCESS, + status: status, + }; +}; + +export function unreblogFail(status, error) { + return { + type: UNREBLOG_FAIL, + status: status, + error: error, + }; +}; + +export function favourite(status) { + return function (dispatch, getState) { + dispatch(favouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(favouriteSuccess(status)); + }).catch(function (error) { + dispatch(favouriteFail(status, error)); + }); + }; +}; + +export function unfavourite(status) { + return (dispatch, getState) => { + dispatch(unfavouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unfavouriteSuccess(status)); + }).catch(error => { + dispatch(unfavouriteFail(status, error)); + }); + }; +}; + +export function favouriteRequest(status) { + return { + type: FAVOURITE_REQUEST, + status: status, + }; +}; + +export function favouriteSuccess(status) { + return { + type: FAVOURITE_SUCCESS, + status: status, + }; +}; + +export function favouriteFail(status, error) { + return { + type: FAVOURITE_FAIL, + status: status, + error: error, + }; +}; + +export function unfavouriteRequest(status) { + return { + type: UNFAVOURITE_REQUEST, + status: status, + }; +}; + +export function unfavouriteSuccess(status) { + return { + type: UNFAVOURITE_SUCCESS, + status: status, + }; +}; + +export function unfavouriteFail(status, error) { + return { + type: UNFAVOURITE_FAIL, + status: status, + error: error, + }; +}; + +export function bookmark(status) { + return function (dispatch, getState) { + dispatch(bookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(bookmarkSuccess(status)); + }).catch(function (error) { + dispatch(bookmarkFail(status, error)); + }); + }; +}; + +export function unbookmark(status) { + return (dispatch, getState) => { + dispatch(unbookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unbookmarkSuccess(status)); + }).catch(error => { + dispatch(unbookmarkFail(status, error)); + }); + }; +}; + +export function bookmarkRequest(status) { + return { + type: BOOKMARK_REQUEST, + status: status, + }; +}; + +export function bookmarkSuccess(status) { + return { + type: BOOKMARK_SUCCESS, + status: status, + }; +}; + +export function bookmarkFail(status, error) { + return { + type: BOOKMARK_FAIL, + status: status, + error: error, + }; +}; + +export function unbookmarkRequest(status) { + return { + type: UNBOOKMARK_REQUEST, + status: status, + }; +}; + +export function unbookmarkSuccess(status) { + return { + type: UNBOOKMARK_SUCCESS, + status: status, + }; +}; + +export function unbookmarkFail(status, error) { + return { + type: UNBOOKMARK_FAIL, + status: status, + error: error, + }; +}; + +export function fetchReblogs(id) { + return (dispatch, getState) => { + dispatch(fetchReblogsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchReblogsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchReblogsFail(id, error)); + }); + }; +}; + +export function fetchReblogsRequest(id) { + return { + type: REBLOGS_FETCH_REQUEST, + id, + }; +}; + +export function fetchReblogsSuccess(id, accounts) { + return { + type: REBLOGS_FETCH_SUCCESS, + id, + accounts, + }; +}; + +export function fetchReblogsFail(id, error) { + return { + type: REBLOGS_FETCH_FAIL, + error, + }; +}; + +export function fetchFavourites(id) { + return (dispatch, getState) => { + dispatch(fetchFavouritesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFavouritesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchFavouritesFail(id, error)); + }); + }; +}; + +export function fetchFavouritesRequest(id) { + return { + type: FAVOURITES_FETCH_REQUEST, + id, + }; +}; + +export function fetchFavouritesSuccess(id, accounts) { + return { + type: FAVOURITES_FETCH_SUCCESS, + id, + accounts, + }; +}; + +export function fetchFavouritesFail(id, error) { + return { + type: FAVOURITES_FETCH_FAIL, + error, + }; +}; + +export function pin(status) { + return (dispatch, getState) => { + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(pinSuccess(status)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; +}; + +export function pinRequest(status) { + return { + type: PIN_REQUEST, + status, + }; +}; + +export function pinSuccess(status) { + return { + type: PIN_SUCCESS, + status, + }; +}; + +export function pinFail(status, error) { + return { + type: PIN_FAIL, + status, + error, + }; +}; + +export function unpin (status) { + return (dispatch, getState) => { + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unpinSuccess(status)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; +}; + +export function unpinRequest(status) { + return { + type: UNPIN_REQUEST, + status, + }; +}; + +export function unpinSuccess(status) { + return { + type: UNPIN_SUCCESS, + status, + }; +}; + +export function unpinFail(status, error) { + return { + type: UNPIN_FAIL, + status, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js new file mode 100644 index 000000000..c2309b8c2 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/lists.js @@ -0,0 +1,372 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; +import { showAlertForError } from './alerts'; + +export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; +export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; +export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; + +export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; +export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; +export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; + +export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; +export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; +export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; + +export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; +export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; +export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; + +export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; +export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; +export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; + +export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; +export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; +export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; + +export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; +export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; +export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; + +export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; +export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; +export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; + +export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; +export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; +export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; + +export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; +export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; +export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; + +export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; +export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; + +export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; +export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; +export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; + +export const fetchList = id => (dispatch, getState) => { + if (getState().getIn(['lists', id])) { + return; + } + + dispatch(fetchListRequest(id)); + + api(getState).get(`/api/v1/lists/${id}`) + .then(({ data }) => dispatch(fetchListSuccess(data))) + .catch(err => dispatch(fetchListFail(id, err))); +}; + +export const fetchListRequest = id => ({ + type: LIST_FETCH_REQUEST, + id, +}); + +export const fetchListSuccess = list => ({ + type: LIST_FETCH_SUCCESS, + list, +}); + +export const fetchListFail = (id, error) => ({ + type: LIST_FETCH_FAIL, + id, + error, +}); + +export const fetchLists = () => (dispatch, getState) => { + dispatch(fetchListsRequest()); + + api(getState).get('/api/v1/lists') + .then(({ data }) => dispatch(fetchListsSuccess(data))) + .catch(err => dispatch(fetchListsFail(err))); +}; + +export const fetchListsRequest = () => ({ + type: LISTS_FETCH_REQUEST, +}); + +export const fetchListsSuccess = lists => ({ + type: LISTS_FETCH_SUCCESS, + lists, +}); + +export const fetchListsFail = error => ({ + type: LISTS_FETCH_FAIL, + error, +}); + +export const submitListEditor = shouldReset => (dispatch, getState) => { + const listId = getState().getIn(['listEditor', 'listId']); + const title = getState().getIn(['listEditor', 'title']); + + if (listId === null) { + dispatch(createList(title, shouldReset)); + } else { + dispatch(updateList(listId, title, shouldReset)); + } +}; + +export const setupListEditor = listId => (dispatch, getState) => { + dispatch({ + type: LIST_EDITOR_SETUP, + list: getState().getIn(['lists', listId]), + }); + + dispatch(fetchListAccounts(listId)); +}; + +export const changeListEditorTitle = value => ({ + type: LIST_EDITOR_TITLE_CHANGE, + value, +}); + +export const createList = (title, shouldReset) => (dispatch, getState) => { + dispatch(createListRequest()); + + api(getState).post('/api/v1/lists', { title }).then(({ data }) => { + dispatch(createListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(createListFail(err))); +}; + +export const createListRequest = () => ({ + type: LIST_CREATE_REQUEST, +}); + +export const createListSuccess = list => ({ + type: LIST_CREATE_SUCCESS, + list, +}); + +export const createListFail = error => ({ + type: LIST_CREATE_FAIL, + error, +}); + +export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => { + dispatch(updateListRequest(id)); + + api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => { + dispatch(updateListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(updateListFail(id, err))); +}; + +export const updateListRequest = id => ({ + type: LIST_UPDATE_REQUEST, + id, +}); + +export const updateListSuccess = list => ({ + type: LIST_UPDATE_SUCCESS, + list, +}); + +export const updateListFail = (id, error) => ({ + type: LIST_UPDATE_FAIL, + id, + error, +}); + +export const resetListEditor = () => ({ + type: LIST_EDITOR_RESET, +}); + +export const deleteList = id => (dispatch, getState) => { + dispatch(deleteListRequest(id)); + + api(getState).delete(`/api/v1/lists/${id}`) + .then(() => dispatch(deleteListSuccess(id))) + .catch(err => dispatch(deleteListFail(id, err))); +}; + +export const deleteListRequest = id => ({ + type: LIST_DELETE_REQUEST, + id, +}); + +export const deleteListSuccess = id => ({ + type: LIST_DELETE_SUCCESS, + id, +}); + +export const deleteListFail = (id, error) => ({ + type: LIST_DELETE_FAIL, + id, + error, +}); + +export const fetchListAccounts = listId => (dispatch, getState) => { + dispatch(fetchListAccountsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListAccountsSuccess(listId, data)); + }).catch(err => dispatch(fetchListAccountsFail(listId, err))); +}; + +export const fetchListAccountsRequest = id => ({ + type: LIST_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchListAccountsSuccess = (id, accounts, next) => ({ + type: LIST_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchListAccountsFail = (id, error) => ({ + type: LIST_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchListSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchListSuggestionsReady = (query, accounts) => ({ + type: LIST_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearListSuggestions = () => ({ + type: LIST_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeListSuggestions = value => ({ + type: LIST_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToListEditor = accountId => (dispatch, getState) => { + dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const addToList = (listId, accountId) => (dispatch, getState) => { + dispatch(addToListRequest(listId, accountId)); + + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToListSuccess(listId, accountId))) + .catch(err => dispatch(addToListFail(listId, accountId, err))); +}; + +export const addToListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_REQUEST, + listId, + accountId, +}); + +export const addToListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_SUCCESS, + listId, + accountId, +}); + +export const addToListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_ADD_FAIL, + listId, + accountId, + error, +}); + +export const removeFromListEditor = accountId => (dispatch, getState) => { + dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const removeFromList = (listId, accountId) => (dispatch, getState) => { + dispatch(removeFromListRequest(listId, accountId)); + + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromListSuccess(listId, accountId))) + .catch(err => dispatch(removeFromListFail(listId, accountId, err))); +}; + +export const removeFromListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_REQUEST, + listId, + accountId, +}); + +export const removeFromListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_SUCCESS, + listId, + accountId, +}); + +export const removeFromListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_REMOVE_FAIL, + listId, + accountId, + error, +}); + +export const resetListAdder = () => ({ + type: LIST_ADDER_RESET, +}); + +export const setupListAdder = accountId => (dispatch, getState) => { + dispatch({ + type: LIST_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchLists()); + dispatch(fetchAccountLists(accountId)); +}; + +export const fetchAccountLists = accountId => (dispatch, getState) => { + dispatch(fetchAccountListsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/lists`) + .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountListsFail(accountId, err))); +}; + +export const fetchAccountListsRequest = id => ({ + type:LIST_ADDER_LISTS_FETCH_REQUEST, + id, +}); + +export const fetchAccountListsSuccess = (id, lists) => ({ + type: LIST_ADDER_LISTS_FETCH_SUCCESS, + id, + lists, +}); + +export const fetchAccountListsFail = (id, err) => ({ + type: LIST_ADDER_LISTS_FETCH_FAIL, + id, + err, +}); + +export const addToListAdder = listId => (dispatch, getState) => { + dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); +}; + +export const removeFromListAdder = listId => (dispatch, getState) => { + dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); +}; + diff --git a/app/javascript/flavours/glitch/actions/local_settings.js b/app/javascript/flavours/glitch/actions/local_settings.js new file mode 100644 index 000000000..28660a4e8 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/local_settings.js @@ -0,0 +1,24 @@ +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +}; + +// __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/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js new file mode 100644 index 000000000..80e15c28e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/modal.js @@ -0,0 +1,16 @@ +export const MODAL_OPEN = 'MODAL_OPEN'; +export const MODAL_CLOSE = 'MODAL_CLOSE'; + +export function openModal(type, props) { + return { + type: MODAL_OPEN, + modalType: type, + modalProps: props, + }; +}; + +export function closeModal() { + return { + type: MODAL_CLOSE, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js new file mode 100644 index 000000000..927fc7415 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -0,0 +1,106 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; +import { openModal } from 'flavours/glitch/actions/modal'; + +export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; +export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; +export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; + +export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; +export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; +export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; + +export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; + +export function fetchMutes() { + return (dispatch, getState) => { + dispatch(fetchMutesRequest()); + + api(getState).get('/api/v1/mutes').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchMutesFail(error))); + }; +}; + +export function fetchMutesRequest() { + return { + type: MUTES_FETCH_REQUEST, + }; +}; + +export function fetchMutesSuccess(accounts, next) { + return { + type: MUTES_FETCH_SUCCESS, + accounts, + next, + }; +}; + +export function fetchMutesFail(error) { + return { + type: MUTES_FETCH_FAIL, + error, + }; +}; + +export function expandMutes() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mutes', 'next']); + + if (url === null) { + return; + } + + dispatch(expandMutesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMutesFail(error))); + }; +}; + +export function expandMutesRequest() { + return { + type: MUTES_EXPAND_REQUEST, + }; +}; + +export function expandMutesSuccess(accounts, next) { + return { + type: MUTES_EXPAND_SUCCESS, + accounts, + next, + }; +}; + +export function expandMutesFail(error) { + return { + type: MUTES_EXPAND_FAIL, + error, + }; +}; + +export function initMuteModal(account) { + return dispatch => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; +} + +export function toggleHideNotifications() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js new file mode 100644 index 000000000..57fecf63d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -0,0 +1,292 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import IntlMessageFormat from 'intl-messageformat'; +import { fetchRelationships } from './accounts'; +import { + importFetchedAccount, + importFetchedAccounts, + importFetchedStatus, + importFetchedStatuses, +} from './importer'; +import { saveSettings } from './settings'; +import { defineMessages } from 'react-intl'; +import { List as ImmutableList } from 'immutable'; +import { unescapeHTML } from 'flavours/glitch/util/html'; +import { getFilters, regexFromFilters } from 'flavours/glitch/selectors'; + +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_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; + +export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; + +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; + +export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; +export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; + +export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; + +defineMessages({ + mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, +}); + +const fetchRelatedRelationships = (dispatch, notifications) => { + const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + + if (accountIds > 0) { + dispatch(fetchRelationships(accountIds)); + } +}; + +export function updateNotifications(notification, intlMessages, intlLocale) { + return (dispatch, getState) => { + const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + const filters = getFilters(getState(), { contextType: 'notifications' }); + + let filtered = false; + + if (notification.type === 'mention') { + const regex = regexFromFilters(filters); + const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + + filtered = regex && regex.test(searchIndex); + } + + if (showInColumn) { + dispatch(importFetchedAccount(notification.account)); + + if (notification.status) { + dispatch(importFetchedStatus(notification.status)); + } + + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, + }); + + fetchRelatedRelationships(dispatch, [notification]); + } else if (playSound && !filtered) { + dispatch({ + type: NOTIFICATIONS_UPDATE_NOOP, + meta: { sound: 'boop' }, + }); + } + + // Desktop notifications + if (typeof window.Notification !== 'undefined' && showAlert && !filtered) { + const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); + + const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); + notify.addEventListener('click', () => { + window.focus(); + notify.close(); + }); + } + }; +}; + +const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); + + +const excludeTypesFromFilter = filter => { + const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']); + return allTypes.filterNot(item => item === filter).toJS(); +}; + +const noOp = () => {}; + +export function expandNotifications({ maxId } = {}, done = noOp) { + return (dispatch, getState) => { + const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); + const notifications = getState().get('notifications'); + const isLoadingMore = !!maxId; + + if (notifications.get('isLoading')) { + done(); + return; + } + + const params = { + max_id: maxId, + exclude_types: activeFilter === 'all' + ? excludeTypesFromSettings(getState()) + : excludeTypesFromFilter(activeFilter), + }; + + if (!maxId && notifications.get('items').size > 0) { + params.since_id = notifications.getIn(['items', 0, 'id']); + } + + dispatch(expandNotificationsRequest(isLoadingMore)); + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); + fetchRelatedRelationships(dispatch, response.data); + done(); + }).catch(error => { + dispatch(expandNotificationsFail(error, isLoadingMore)); + done(); + }); + }; +}; + +export function expandNotificationsRequest(isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_REQUEST, + skipLoading: !isLoadingMore, + }; +}; + +export function expandNotificationsSuccess(notifications, next, isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status).filter(status => !!status), + next, + skipLoading: !isLoadingMore, + }; +}; + +export function expandNotificationsFail(error, isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_FAIL, + error, + skipLoading: !isLoadingMore, + }; +}; + +export function clearNotifications() { + return (dispatch, getState) => { + dispatch({ + type: NOTIFICATIONS_CLEAR, + }); + + api(getState).post('/api/v1/notifications/clear'); + }; +}; + +export function scrollTopNotifications(top) { + return { + type: NOTIFICATIONS_SCROLL_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, + }; +}; + +export function mountNotifications() { + return { + type: NOTIFICATIONS_MOUNT, + }; +}; + +export function unmountNotifications() { + return { + type: NOTIFICATIONS_UNMOUNT, + }; +}; + +export function notificationsSetVisibility(visibility) { + return { + type: NOTIFICATIONS_SET_VISIBILITY, + visibility: visibility, + }; +}; + +export function setFilter (filterType) { + return dispatch => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + dispatch(expandNotifications()); + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/flavours/glitch/actions/onboarding.js b/app/javascript/flavours/glitch/actions/onboarding.js new file mode 100644 index 000000000..a161c50ef --- /dev/null +++ b/app/javascript/flavours/glitch/actions/onboarding.js @@ -0,0 +1,14 @@ +import { openModal } from './modal'; +import { changeSetting, saveSettings } from './settings'; + +export function showOnboardingOnce() { + return (dispatch, getState) => { + const alreadySeen = getState().getIn(['settings', 'onboarded']); + + if (!alreadySeen) { + dispatch(openModal('ONBOARDING')); + dispatch(changeSetting(['onboarded'], true)); + dispatch(saveSettings()); + } + }; +}; diff --git a/app/javascript/flavours/glitch/actions/pin_statuses.js b/app/javascript/flavours/glitch/actions/pin_statuses.js new file mode 100644 index 000000000..77dfb9c7f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/pin_statuses.js @@ -0,0 +1,42 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedStatuses } from './importer'; + +export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; +export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; +export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; + +import { me } from 'flavours/glitch/util/initial_state'; + +export function fetchPinnedStatuses() { + return (dispatch, getState) => { + dispatch(fetchPinnedStatusesRequest()); + + api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchPinnedStatusesSuccess(response.data, null)); + }).catch(error => { + dispatch(fetchPinnedStatusesFail(error)); + }); + }; +}; + +export function fetchPinnedStatusesRequest() { + return { + type: PINNED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchPinnedStatusesSuccess(statuses, next) { + return { + type: PINNED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchPinnedStatusesFail(error) { + return { + type: PINNED_STATUSES_FETCH_FAIL, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js new file mode 100644 index 000000000..8e8b82df5 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/polls.js @@ -0,0 +1,60 @@ +import api from '../api'; +import { importFetchedPoll } from './importer'; + +export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; +export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; +export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; + +export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; +export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; +export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; + +export const vote = (pollId, choices) => (dispatch, getState) => { + dispatch(voteRequest()); + + api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(voteSuccess(data)); + }) + .catch(err => dispatch(voteFail(err))); +}; + +export const fetchPoll = pollId => (dispatch, getState) => { + dispatch(fetchPollRequest()); + + api(getState).get(`/api/v1/polls/${pollId}`) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(fetchPollSuccess(data)); + }) + .catch(err => dispatch(fetchPollFail(err))); +}; + +export const voteRequest = () => ({ + type: POLL_VOTE_REQUEST, +}); + +export const voteSuccess = poll => ({ + type: POLL_VOTE_SUCCESS, + poll, +}); + +export const voteFail = error => ({ + type: POLL_VOTE_FAIL, + error, +}); + +export const fetchPollRequest = () => ({ + type: POLL_FETCH_REQUEST, +}); + +export const fetchPollSuccess = poll => ({ + type: POLL_FETCH_SUCCESS, + poll, +}); + +export const fetchPollFail = error => ({ + type: POLL_FETCH_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/push_notifications/index.js b/app/javascript/flavours/glitch/actions/push_notifications/index.js new file mode 100644 index 000000000..2ffec500a --- /dev/null +++ b/app/javascript/flavours/glitch/actions/push_notifications/index.js @@ -0,0 +1,23 @@ +import { + SET_BROWSER_SUPPORT, + SET_SUBSCRIPTION, + CLEAR_SUBSCRIPTION, + SET_ALERTS, + setAlerts, +} from './setter'; +import { register, saveSettings } from './registerer'; + +export { + SET_BROWSER_SUPPORT, + SET_SUBSCRIPTION, + CLEAR_SUBSCRIPTION, + SET_ALERTS, + register, +}; + +export function changeAlerts(path, value) { + return dispatch => { + dispatch(setAlerts(path, value)); + dispatch(saveSettings()); + }; +} diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js new file mode 100644 index 000000000..8fdb239f7 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js @@ -0,0 +1,139 @@ +import api from 'flavours/glitch/util/api'; +import { pushNotificationsSetting } from 'flavours/glitch/util/settings'; +import { setBrowserSupport, setSubscription, clearSubscription } from './setter'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (getState, subscription, me) => { + const params = { subscription }; + + if (me) { + const data = pushNotificationsSetting.get(me); + if (data) { + params.data = data; + } + } + + return api(getState).post('/api/web/push_subscriptions', params).then(response => response.data); +}; + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + return (dispatch, getState) => { + dispatch(setBrowserSupport(supportsPushNotifications)); + const me = getState().getIn(['meta', 'me']); + + if (supportsPushNotifications) { + if (!getApplicationServerKey()) { + console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); + return; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then( + subscription => sendSubscriptionToBackend(getState, subscription, me)); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then( + subscription => sendSubscriptionToBackend(getState, subscription, me)); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + dispatch(setSubscription(subscription)); + if (me) { + pushNotificationsSetting.set(me, { alerts: subscription.alerts }); + } + } + }) + .catch(error => { + if (error.code === 20 && error.name === 'AbortError') { + console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); + } else if (error.code === 5 && error.name === 'InvalidCharacterError') { + console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); + } + + // Clear alerts and hide UI settings + dispatch(clearSubscription()); + if (me) { + pushNotificationsSetting.remove(me); + } + + return getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + }) + .catch(console.warn); + } else { + console.warn('Your browser does not support Web Push Notifications.'); + } + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + const data = { alerts }; + + api(getState).put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data, + }).then(() => { + const me = getState().getIn(['meta', 'me']); + if (me) { + pushNotificationsSetting.set(me, data); + } + }).catch(console.warn); + }; +} diff --git a/app/javascript/flavours/glitch/actions/push_notifications/setter.js b/app/javascript/flavours/glitch/actions/push_notifications/setter.js new file mode 100644 index 000000000..5561766bf --- /dev/null +++ b/app/javascript/flavours/glitch/actions/push_notifications/setter.js @@ -0,0 +1,34 @@ +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function setAlerts (path, value) { + return dispatch => { + dispatch({ + type: SET_ALERTS, + path, + value, + }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/reports.js b/app/javascript/flavours/glitch/actions/reports.js new file mode 100644 index 000000000..80c3b3280 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/reports.js @@ -0,0 +1,89 @@ +import api from 'flavours/glitch/util/api'; +import { openModal, closeModal } from './modal'; + +export const REPORT_INIT = 'REPORT_INIT'; +export const REPORT_CANCEL = 'REPORT_CANCEL'; + +export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; +export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; +export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; + +export function initReport(account, status) { + return dispatch => { + dispatch({ + type: REPORT_INIT, + account, + status, + }); + + dispatch(openModal('REPORT')); + }; +}; + +export function cancelReport() { + return { + type: REPORT_CANCEL, + }; +}; + +export function toggleStatusReport(statusId, checked) { + return { + type: REPORT_STATUS_TOGGLE, + statusId, + checked, + }; +}; + +export function submitReport() { + return (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', { + account_id: getState().getIn(['reports', 'new', 'account_id']), + status_ids: getState().getIn(['reports', 'new', 'status_ids']), + comment: getState().getIn(['reports', 'new', 'comment']), + forward: getState().getIn(['reports', 'new', 'forward']), + }).then(response => { + dispatch(closeModal()); + dispatch(submitReportSuccess(response.data)); + }).catch(error => dispatch(submitReportFail(error))); + }; +}; + +export function submitReportRequest() { + return { + type: REPORT_SUBMIT_REQUEST, + }; +}; + +export function submitReportSuccess(report) { + return { + type: REPORT_SUBMIT_SUCCESS, + report, + }; +}; + +export function submitReportFail(error) { + return { + type: REPORT_SUBMIT_FAIL, + error, + }; +}; + +export function changeReportComment(comment) { + return { + type: REPORT_COMMENT_CHANGE, + comment, + }; +}; + +export function changeReportForward(forward) { + return { + type: REPORT_FORWARD_CHANGE, + forward, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js new file mode 100644 index 000000000..b2d24e10b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/search.js @@ -0,0 +1,85 @@ +import api from 'flavours/glitch/util/api'; +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +export const SEARCH_CHANGE = 'SEARCH_CHANGE'; +export const SEARCH_CLEAR = 'SEARCH_CLEAR'; +export const SEARCH_SHOW = 'SEARCH_SHOW'; + +export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; + +export function changeSearch(value) { + return { + type: SEARCH_CHANGE, + value, + }; +}; + +export function clearSearch() { + return { + type: SEARCH_CLEAR, + }; +}; + +export function submitSearch() { + return (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + + if (value.length === 0) { + return; + } + + dispatch(fetchSearchRequest()); + + api(getState).get('/api/v2/search', { + params: { + q: value, + resolve: true, + limit: 10, + }, + }).then(response => { + if (response.data.accounts) { + dispatch(importFetchedAccounts(response.data.accounts)); + } + + if (response.data.statuses) { + dispatch(importFetchedStatuses(response.data.statuses)); + } + + dispatch(fetchSearchSuccess(response.data)); + dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); + }).catch(error => { + dispatch(fetchSearchFail(error)); + }); + }; +}; + +export function fetchSearchRequest() { + return { + type: SEARCH_FETCH_REQUEST, + }; +}; + +export function fetchSearchSuccess(results) { + return { + type: SEARCH_FETCH_SUCCESS, + results, + accounts: results.accounts, + statuses: results.statuses, + }; +}; + +export function fetchSearchFail(error) { + return { + type: SEARCH_FETCH_FAIL, + error, + }; +}; + +export function showSearch() { + return { + type: SEARCH_SHOW, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/settings.js b/app/javascript/flavours/glitch/actions/settings.js new file mode 100644 index 000000000..fb0bcc09c --- /dev/null +++ b/app/javascript/flavours/glitch/actions/settings.js @@ -0,0 +1,34 @@ +import api from 'flavours/glitch/util/api'; +import { debounce } from 'lodash'; +import { showAlertForError } from './alerts'; + +export const SETTING_CHANGE = 'SETTING_CHANGE'; +export const SETTING_SAVE = 'SETTING_SAVE'; + +export function changeSetting(path, value) { + return dispatch => { + dispatch({ + type: SETTING_CHANGE, + path, + value, + }); + + dispatch(saveSettings()); + }; +}; + +const debouncedSave = debounce((dispatch, getState) => { + if (getState().getIn(['settings', 'saved'])) { + return; + } + + const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS(); + + api(getState).put('/api/web/settings', { data }) + .then(() => dispatch({ type: SETTING_SAVE })) + .catch(error => dispatch(showAlertForError(error))); +}, 5000, { trailing: true }); + +export function saveSettings() { + return (dispatch, getState) => debouncedSave(dispatch, getState); +}; diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js new file mode 100644 index 000000000..4eabc4be0 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -0,0 +1,240 @@ +import api from 'flavours/glitch/util/api'; + +import { deleteFromTimelines } from './timelines'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; + +export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; +export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; +export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; + +export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + +export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; +export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; +export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; + +export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; +export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; +export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; + +export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; +export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; +export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; + +export const REDRAFT = 'REDRAFT'; + +export function fetchStatusRequest(id, skipLoading) { + return { + type: STATUS_FETCH_REQUEST, + id, + skipLoading, + }; +}; + +export function fetchStatus(id) { + return (dispatch, getState) => { + const skipLoading = getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchContext(id)); + + if (skipLoading) { + return; + } + + dispatch(fetchStatusRequest(id, skipLoading)); + + api(getState).get(`/api/v1/statuses/${id}`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(fetchStatusSuccess(skipLoading)); + }).catch(error => { + dispatch(fetchStatusFail(id, error, skipLoading)); + }); + }; +}; + +export function fetchStatusSuccess(skipLoading) { + return { + type: STATUS_FETCH_SUCCESS, + skipLoading, + }; +}; + +export function fetchStatusFail(id, error, skipLoading) { + return { + type: STATUS_FETCH_FAIL, + id, + error, + skipLoading, + skipAlert: true, + }; +}; + +export function redraft(status) { + return { + type: REDRAFT, + status, + }; +}; + +export function deleteStatus(id, router, withRedraft = false) { + return (dispatch, getState) => { + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } + + dispatch(deleteStatusRequest(id)); + + api(getState).delete(`/api/v1/statuses/${id}`).then(() => { + dispatch(deleteStatusSuccess(id)); + dispatch(deleteFromTimelines(id)); + + if (withRedraft) { + dispatch(redraft(status)); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + } + }).catch(error => { + dispatch(deleteStatusFail(id, error)); + }); + }; +}; + +export function deleteStatusRequest(id) { + return { + type: STATUS_DELETE_REQUEST, + id: id, + }; +}; + +export function deleteStatusSuccess(id) { + return { + type: STATUS_DELETE_SUCCESS, + id: id, + }; +}; + +export function deleteStatusFail(id, error) { + return { + type: STATUS_DELETE_FAIL, + id: id, + error: error, + }; +}; + +export function fetchContext(id) { + return (dispatch, getState) => { + dispatch(fetchContextRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { + dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); + dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); + + }).catch(error => { + if (error.response && error.response.status === 404) { + dispatch(deleteFromTimelines(id)); + } + + dispatch(fetchContextFail(id, error)); + }); + }; +}; + +export function fetchContextRequest(id) { + return { + type: CONTEXT_FETCH_REQUEST, + id, + }; +}; + +export function fetchContextSuccess(id, ancestors, descendants) { + return { + type: CONTEXT_FETCH_SUCCESS, + id, + ancestors, + descendants, + statuses: ancestors.concat(descendants), + }; +}; + +export function fetchContextFail(id, error) { + return { + type: CONTEXT_FETCH_FAIL, + id, + error, + skipAlert: true, + }; +}; + +export function muteStatus(id) { + return (dispatch, getState) => { + dispatch(muteStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + dispatch(muteStatusSuccess(id)); + }).catch(error => { + dispatch(muteStatusFail(id, error)); + }); + }; +}; + +export function muteStatusRequest(id) { + return { + type: STATUS_MUTE_REQUEST, + id, + }; +}; + +export function muteStatusSuccess(id) { + return { + type: STATUS_MUTE_SUCCESS, + id, + }; +}; + +export function muteStatusFail(id, error) { + return { + type: STATUS_MUTE_FAIL, + id, + error, + }; +}; + +export function unmuteStatus(id) { + return (dispatch, getState) => { + dispatch(unmuteStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { + dispatch(unmuteStatusSuccess(id)); + }).catch(error => { + dispatch(unmuteStatusFail(id, error)); + }); + }; +}; + +export function unmuteStatusRequest(id) { + return { + type: STATUS_UNMUTE_REQUEST, + id, + }; +}; + +export function unmuteStatusSuccess(id) { + return { + type: STATUS_UNMUTE_SUCCESS, + id, + }; +}; + +export function unmuteStatusFail(id, error) { + return { + type: STATUS_UNMUTE_FAIL, + id, + error, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js new file mode 100644 index 000000000..34dcafc51 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/store.js @@ -0,0 +1,24 @@ +import { Iterable, fromJS } from 'immutable'; +import { hydrateCompose } from './compose'; +import { importFetchedAccounts } from './importer'; + +export const STORE_HYDRATE = 'STORE_HYDRATE'; +export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; + +const convertState = rawState => + fromJS(rawState, (k, v) => + Iterable.isIndexed(v) ? v.toList() : v.toMap()); + +export function hydrateStore(rawState) { + return dispatch => { + const state = convertState(rawState); + + dispatch({ + type: STORE_HYDRATE, + state, + }); + + dispatch(hydrateCompose()); + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + }; +}; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js new file mode 100644 index 000000000..b5dd70989 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -0,0 +1,58 @@ +import { connectStream } from 'flavours/glitch/util/stream'; +import { + updateTimeline, + deleteFromTimelines, + expandHomeTimeline, + connectTimeline, + disconnectTimeline, +} from './timelines'; +import { updateNotifications, expandNotifications } from './notifications'; +import { fetchFilters } from './filters'; +import { getLocale } from 'mastodon/locales'; + +const { messages } = getLocale(); + +export function connectTimelineStream (timelineId, path, pollingRefresh = null, accept = null) { + + return connectStream (path, pollingRefresh, (dispatch, getState) => { + const locale = getState().getIn(['meta', 'locale']); + + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + }, + + onReceive (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline(timelineId, JSON.parse(data.payload), accept)); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + case 'filters_changed': + dispatch(fetchFilters()); + break; + } + }, + }; + }); +} + +const refreshHomeTimelineAndNotification = (dispatch, done) => { + dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); +}; + +export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); +export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); +export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); +export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); +export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); +export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js new file mode 100644 index 000000000..3687136ff --- /dev/null +++ b/app/javascript/flavours/glitch/actions/suggestions.js @@ -0,0 +1,52 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; + +export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; +export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; +export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; + +export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; + +export function fetchSuggestions() { + return (dispatch, getState) => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v1/suggestions').then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchSuggestionsSuccess(response.data)); + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }; +}; + +export function fetchSuggestionsRequest() { + return { + type: SUGGESTIONS_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchSuggestionsSuccess(accounts) { + return { + type: SUGGESTIONS_FETCH_SUCCESS, + accounts, + skipLoading: true, + }; +}; + +export function fetchSuggestionsFail(error) { + return { + type: SUGGESTIONS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +}; + +export const dismissSuggestion = accountId => (dispatch, getState) => { + dispatch({ + type: SUGGESTIONS_DISMISS, + id: accountId, + }); + + api(getState).delete(`/api/v1/suggestions/${accountId}`); +}; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js new file mode 100644 index 000000000..cca571583 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -0,0 +1,161 @@ +import { importFetchedStatus, importFetchedStatuses } from './importer'; +import api, { getLinks } from 'flavours/glitch/util/api'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +export const TIMELINE_DELETE = 'TIMELINE_DELETE'; +export const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; + +export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; +export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; +export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; + +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; + +export function updateTimeline(timeline, status, accept) { + return dispatch => { + if (typeof accept === 'function' && !accept(status)) { + return; + } + + dispatch(importFetchedStatus(status)); + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + status, + }); + }; +}; + +export function deleteFromTimelines(id) { + return (dispatch, getState) => { + const accountId = getState().getIn(['statuses', id, 'account']); + const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); + + dispatch({ + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf, + }); + }; +}; + +export function clearTimeline(timeline) { + return (dispatch) => { + dispatch({ type: TIMELINE_CLEAR, timeline }); + }; +}; + +const noOp = () => {}; + +const parseTags = (tags = {}, mode) => { + return (tags[mode] || []).map((tag) => { + return tag.value; + }); +}; + +export function expandTimeline(timelineId, path, params = {}, done = noOp) { + return (dispatch, getState) => { + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); + const isLoadingMore = !!params.max_id; + + if (timeline.get('isLoading')) { + done(); + return; + } + + if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { + params.since_id = timeline.getIn(['items', 0]); + } + + const isLoadingRecent = !!params.since_id; + + dispatch(expandTimelineRequest(timelineId, isLoadingMore)); + + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); + done(); + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + done(); + }); + }; +}; + +export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); +export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); +export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); +export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); +export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); +export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); + +export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + }, done); +}; + +export function expandTimelineRequest(timeline, isLoadingMore) { + return { + type: TIMELINE_EXPAND_REQUEST, + timeline, + skipLoading: !isLoadingMore, + }; +}; + +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { + return { + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + partial, + isLoadingRecent, + skipLoading: !isLoadingMore, + }; +}; + +export function expandTimelineFail(timeline, error, isLoadingMore) { + return { + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + skipLoading: !isLoadingMore, + }; +}; + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top, + }; +}; + +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline, + }; +}; + +export function disconnectTimeline(timeline) { + return { + type: TIMELINE_DISCONNECT, + timeline, + }; +}; diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js new file mode 100644 index 000000000..3fc18cb72 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account.js @@ -0,0 +1,153 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import DisplayName from './display_name'; +import Permalink from './permalink'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + 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: '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 +export default class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onMuteNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, + small: PropTypes.bool, + actionIcon: PropTypes.string, + actionTitle: PropTypes.string, + onActionClick: PropTypes.func, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + handleMuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, true); + } + + handleUnmuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, false); + } + + handleAction = () => { + this.props.onActionClick(this.props.account); + } + + render () { + const { + account, + hidden, + intl, + small, + onActionClick, + actionIcon, + actionTitle, + } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <Fragment> + {account.get('display_name')} + {account.get('username')} + </Fragment> + ); + } + + let buttons; + + if (onActionClick && actionIcon) { + buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />; + } else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + } else if (blocking) { + buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + let hidingNotificationsButton; + if (account.getIn(['relationship', 'muting_notifications'])) { + hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; + } else { + hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; + } + buttons = ( + <Fragment> + <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> + {hidingNotificationsButton} + </Fragment> + ); + } else if (!account.get('moved') || following) { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } + } + + return small ? ( + <Permalink + className='account small' + href={account.get('url')} + to={`/accounts/${account.get('id')}`} + > + <div className='account__avatar-wrapper'> + <Avatar + account={account} + size={24} + /> + </div> + <DisplayName + account={account} + inline + /> + </Permalink> + ) : ( + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + {buttons ? + <div className='account__relationship'> + {buttons} + </div> + : null} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js new file mode 100644 index 000000000..8e5bb0e0b --- /dev/null +++ b/app/javascript/flavours/glitch/components/attachment_list.js @@ -0,0 +1,57 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; + +export default class AttachmentList extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + compact: PropTypes.bool, + }; + + render () { + const { media, compact } = this.props; + + if (compact) { + return ( + <div className='attachment-list compact'> + <ul className='attachment-list__list'> + {media.map(attachment => { + const displayUrl = attachment.get('remote_url') || attachment.get('url'); + + return ( + <li key={attachment.get('id')}> + <a href={displayUrl} target='_blank' rel='noopener'><i className='fa fa-link' /> {filename(displayUrl)}</a> + </li> + ); + })} + </ul> + </div> + ); + } + + return ( + <div className='attachment-list'> + <div className='attachment-list__icon'> + <i className='fa fa-link' /> + </div> + + <ul className='attachment-list__list'> + {media.map(attachment => { + const displayUrl = attachment.get('remote_url') || attachment.get('url'); + + return ( + <li key={attachment.get('id')}> + <a href={displayUrl} target='_blank' rel='noopener'>{filename(displayUrl)}</a> + </li> + ); + })} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js new file mode 100644 index 000000000..c8609e48f --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; + +const assetHost = process.env.CDN_HOST || ''; + +export default class AutosuggestEmoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.object.isRequired, + }; + + render () { + const { emoji } = this.props; + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = `${assetHost}/emoji/${mapping.filename}.svg`; + } + + return ( + <div className='emoji'> + <img + className='emojione' + src={url} + alt={emoji.native || emoji.colons} + /> + + {emoji.colons} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js new file mode 100644 index 000000000..ca0dcb64f --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_input.js @@ -0,0 +1,229 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; +import { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestInput extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + autoFocus: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + searchTokens: PropTypes.list, + maxLength: PropTypes.number, + }; + + static defaultProps = { + autoFocus: true, + searchTokens: ImmutableList(['@', ':', '#']), + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.input.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setInput = (c) => { + this.input = c; + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = <AutosuggestAccountContainer id={suggestion} />; + key = suggestion; + } + + return ( + <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( + <div className='autosuggest-input'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <input + type='text' + ref={this.setInput} + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + onFocus={this.onFocus} + onBlur={this.onBlur} + style={style} + aria-autocomplete='list' + id={id} + className={className} + maxLength={maxLength} + /> + </label> + + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(this.renderSuggestion)} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js new file mode 100644 index 000000000..e1ded2b3a --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -0,0 +1,230 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Textarea from 'react-textarea-autosize'; +import classNames from 'classnames'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestTextarea extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + onPaste: PropTypes.func.isRequired, + autoFocus: PropTypes.bool, + }; + + static defaultProps = { + autoFocus: true, + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setTextarea = (c) => { + this.textarea = c; + } + + onPaste = (e) => { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files); + e.preventDefault(); + } + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = <AutosuggestAccountContainer id={suggestion} />; + key = suggestion; + } + + return ( + <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( + <div className='autosuggest-textarea'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <Textarea + inputRef={this.setTextarea} + className='autosuggest-textarea__textarea' + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + onFocus={this.onFocus} + onBlur={this.onBlur} + onPaste={this.onPaste} + style={style} + aria-autocomplete='list' + /> + </label> + + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(this.renderSuggestion)} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar.js b/app/javascript/flavours/glitch/components/avatar.js new file mode 100644 index 000000000..c5e9072c4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar.js @@ -0,0 +1,77 @@ +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class Avatar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + className: PropTypes.string, + size: PropTypes.number.isRequired, + style: PropTypes.object, + inline: PropTypes.bool, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + size: 20, + inline: false, + }; + + state = { + hovering: false, + }; + + handleMouseEnter = () => { + if (this.props.animate) return; + this.setState({ hovering: true }); + } + + handleMouseLeave = () => { + if (this.props.animate) return; + this.setState({ hovering: false }); + } + + render () { + const { + account, + animate, + className, + inline, + size, + } = this.props; + const { hovering } = this.state; + + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + + const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className); + + const style = { + ...this.props.style, + width: `${size}px`, + height: `${size}px`, + backgroundSize: `${size}px ${size}px`, + }; + + if (hovering || animate) { + style.backgroundImage = `url(${src})`; + } else { + style.backgroundImage = `url(${staticSrc})`; + } + + return ( + <div + className={computedClass} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + style={style} + data-avatar-of={`@${account.get('acct')}`} + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.js b/app/javascript/flavours/glitch/components/avatar_overlay.js new file mode 100644 index 000000000..23db5182b --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_overlay.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class AvatarOverlay extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map.isRequired, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + }; + + render() { + const { account, friend, animate } = this.props; + + const baseStyle = { + backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + const overlayStyle = { + backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + return ( + <div className='account__avatar-overlay'> + <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/flavours/glitch/components/button.js b/app/javascript/flavours/glitch/components/button.js new file mode 100644 index 000000000..16868010c --- /dev/null +++ b/app/javascript/flavours/glitch/components/button.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Button extends React.PureComponent { + + static propTypes = { + text: PropTypes.node, + onClick: PropTypes.func, + disabled: PropTypes.bool, + block: PropTypes.bool, + secondary: PropTypes.bool, + size: PropTypes.number, + className: PropTypes.string, + style: PropTypes.object, + children: PropTypes.node, + title: PropTypes.string, + }; + + static defaultProps = { + size: 36, + }; + + handleClick = (e) => { + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + setRef = (c) => { + this.node = c; + } + + focus() { + this.node.focus(); + } + + render () { + 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, + }, + }; + + if (this.props.title) attrs.title = this.props.title; + + return ( + <button {...attrs}> + {this.props.text || this.props.children} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js new file mode 100644 index 000000000..dc87818a5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import detectPassiveEvents from 'detect-passive-events'; +import { scrollTop } from 'flavours/glitch/util/scroll'; + +export default class Column extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + extraClasses: PropTypes.string, + name: PropTypes.string, + label: PropTypes.string, + }; + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = c => { + this.node = c; + } + + componentDidMount () { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + + componentWillUnmount () { + this.node.removeEventListener('wheel', this.handleWheel); + } + + render () { + const { children, extraClasses, name, label } = this.props; + + return ( + <div role='region' aria-label={label} data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}> + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_back_button.js b/app/javascript/flavours/glitch/components/column_back_button.js new file mode 100644 index 000000000..82556d22e --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class ColumnBackButton extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + handleClick = (event) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (event.shiftKey && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + } + + render () { + return ( + <button onClick={this.handleClick} className='column-back-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.js b/app/javascript/flavours/glitch/components/column_back_button_slim.js new file mode 100644 index 000000000..38afd3df3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button_slim.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class ColumnBackButtonSlim extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + handleClick = (event) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (event.shiftKey && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + } + + render () { + return ( + <div className='column-back-button--slim'> + <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js new file mode 100644 index 000000000..a0ff09986 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -0,0 +1,233 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_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 +export default class ColumnHeader extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + title: PropTypes.node, + icon: PropTypes.string, + active: PropTypes.bool, + localSettings : ImmutablePropTypes.map, + multiColumn: PropTypes.bool, + extraButton: PropTypes.node, + 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, + }; + + state = { + collapsed: true, + animating: false, + animatingNCD: false, + }; + + historyBack = (skip) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (skip && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + } + + handleToggleClick = (e) => { + e.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed, animating: true }); + } + + handleTitleClick = () => { + this.props.onClick(); + } + + handleMoveLeft = () => { + this.props.onMove(-1); + } + + handleMoveRight = () => { + this.props.onMove(1); + } + + handleBackClick = (event) => { + this.historyBack(event.shiftKey); + } + + handleTransitionEnd = () => { + this.setState({ animating: false }); + } + + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + } + + handlePin = () => { + if (!this.props.pinned) { + this.historyBack(); + } + this.props.onPin(); + } + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + } + + render () { + const { intl, icon, active, children, pinned, multiColumn, extraButton, 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, + }); + + const buttonClassName = classNames('column-header', { + 'active': active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + 'collapsed': collapsed, + 'animating': animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + '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'> + {children} + </div> + ); + } + + if (multiColumn && pinned) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; + + moveButtons = ( + <div key='move-buttons' className='column-header__setting-arrows'> + <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> + <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> + </div> + ); + } else if (multiColumn) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + } + + if (!pinned && (multiColumn || showBackButton)) { + backButton = ( + <button onClick={this.handleBackClick} className='column-header__back-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || multiColumn) { + collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; + } + + const hasTitle = icon && title; + + return ( + <div className={wrapperClassName}> + <h1 className={buttonClassName}> + {hasTitle && ( + <button onClick={this.handleTitleClick}> + <i className={`fa fa-fw fa-${icon} column-header__icon`} /> + {title} + </button> + )} + + {!hasTitle && backButton} + + <div className='column-header__buttons'> + {hasTitle && backButton} + {extraButton} + { 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} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js new file mode 100644 index 000000000..a26cff049 --- /dev/null +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -0,0 +1,39 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// The component. +export default function DisplayName ({ + account, + className, + inline, + localDomain, +}) { + const computedClass = classNames('display-name', { inline }, className); + + if (!account) return null; + + let acct = account.get('acct'); + if (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; + } + + // The result. + return account ? ( + <span className={computedClass}> + <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> + {inline ? ' ' : null} + <span className='display-name__account'>@{acct}</span> + </span> + ) : null; +} + +// Props. +DisplayName.propTypes = { + account: ImmutablePropTypes.map, + className: PropTypes.string, + inline: PropTypes.bool, + localDomain: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/components/domain.js b/app/javascript/flavours/glitch/components/domain.js new file mode 100644 index 000000000..74174f83d --- /dev/null +++ b/app/javascript/flavours/glitch/components/domain.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +@injectIntl +export default class Account extends ImmutablePureComponent { + + static propTypes = { + domain: PropTypes.string, + onUnblockDomain: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleDomainUnblock = () => { + this.props.onUnblockDomain(this.props.domain); + } + + render () { + const { domain, intl } = this.props; + + return ( + <div className='domain'> + <div className='domain__wrapper'> + <span className='domain__domain-name'> + <strong>{domain}</strong> + </span> + + <div className='domain__buttons'> + <IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js new file mode 100644 index 000000000..05611c135 --- /dev/null +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -0,0 +1,269 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import IconButton from './icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import detectPassiveEvents from 'detect-passive-events'; + +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +let id = 0; + +class DropdownMenu extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + items: PropTypes.array.isRequired, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + style: {}, + placement: 'bottom', + }; + + state = { + mounted: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('keydown', this.handleKeyDown, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); + this.setState({ mounted: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('keydown', this.handleKeyDown, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + setFocusRef = c => { + this.focusedItem = c; + } + + handleKeyDown = e => { + const items = Array.from(this.node.getElementsByTagName('a')); + const index = items.indexOf(document.activeElement); + let element; + + switch(e.key) { + case 'ArrowDown': + element = items[index+1]; + if (element) { + element.focus(); + } + break; + case 'ArrowUp': + element = items[index-1]; + if (element) { + element.focus(); + } + break; + case 'Home': + element = items[0]; + if (element) { + element.focus(); + } + break; + case 'End': + element = items[items.length-1]; + if (element) { + element.focus(); + } + break; + } + } + + handleItemKeyDown = e => { + if (e.key === 'Enter') { + this.handleClick(e); + } + } + + handleClick = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const { action, to } = this.props.items[i]; + + this.props.onClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + renderItem (option, i) { + if (option === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { text, href = '#' } = option; + + return ( + <li className='dropdown-menu__item' key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}> + {text} + </a> + </li> + ); + } + + render () { + const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; + const { mounted } = this.state; + + return ( + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays + <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> + <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> + + <ul> + {items.map((option, i) => this.renderItem(option, i))} + </ul> + </div> + )} + </Motion> + ); + } + +} + +export default class Dropdown extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + icon: PropTypes.string.isRequired, + items: PropTypes.array.isRequired, + size: PropTypes.number.isRequired, + ariaLabel: PropTypes.string, + disabled: PropTypes.bool, + status: ImmutablePropTypes.map, + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onOpen: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + dropdownPlacement: PropTypes.string, + openDropdownId: PropTypes.number, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + ariaLabel: 'Menu', + }; + + state = { + id: id++, + }; + + handleClick = ({ target, type }) => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } else { + const { top } = target.getBoundingClientRect(); + const placement = top * 2 < innerHeight ? 'bottom' : 'top'; + + this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click'); + } + } + + handleClose = () => { + this.props.onClose(this.state.id); + } + + handleKeyDown = e => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleClick(e); + e.preventDefault(); + break; + case 'Escape': + this.handleClose(); + break; + } + } + + handleItemClick = (i, e) => { + const { action, to } = this.props.items[i]; + + this.handleClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + componentWillUnmount = () => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } + } + + render () { + const { icon, items, size, ariaLabel, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props; + const open = this.state.id === openDropdownId; + + return ( + <div onKeyDown={this.handleKeyDown}> + <IconButton + icon={icon} + title={ariaLabel} + active={open} + disabled={disabled} + size={size} + ref={this.setTargetRef} + onClick={this.handleClick} + /> + + <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> + <DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js new file mode 100644 index 000000000..142a0c21a --- /dev/null +++ b/app/javascript/flavours/glitch/components/error_boundary.js @@ -0,0 +1,95 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { preferencesLink } from 'flavours/glitch/util/backend_links'; + +export default class ErrorBoundary extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + }; + + state = { + hasError: false, + stackTrace: undefined, + componentStack: undefined, + } + + componentDidCatch(error, info) { + this.setState({ + hasError: true, + stackTrace: error.stack, + componentStack: info && info.componentStack, + }); + } + + handleReload(e) { + e.preventDefault(); + window.location.reload(); + } + + render() { + const { hasError, stackTrace, componentStack } = this.state; + + if (!hasError) return this.props.children; + + let debugInfo = ''; + if (stackTrace) { + debugInfo += 'Stack trace\n-----------\n\n```\n' + stackTrace.toString() + '\n```'; + } + if (componentStack) { + if (debugInfo) { + debugInfo += '\n\n\n'; + } + debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```'; + } + + return ( + <div tabIndex='-1'> + <div className='error-boundary'> + <h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1> + <p> + <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' /> + <ul> + <li> + <FormattedMessage + id='web_app_crash.report_issue' + defaultMessage='Report a bug in the {issuetracker}' + values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }} + /> + { debugInfo !== '' && ( + <details> + <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary> + <textarea + className='web_app_crash-stacktrace' + value={debugInfo} + rows='10' + readOnly + /> + </details> + )} + </li> + <li> + <FormattedMessage + id='web_app_crash.reload_page' + defaultMessage='{reload} the current page' + values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }} + /> + </li> + { preferencesLink !== undefined && ( + <li> + <FormattedMessage + id='web_app_crash.change_your_settings' + defaultMessage='Change your {settings}' + values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }} + /> + </li> + )} + </ul> + </p> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/extended_video_player.js b/app/javascript/flavours/glitch/components/extended_video_player.js new file mode 100644 index 000000000..009c0d559 --- /dev/null +++ b/app/javascript/flavours/glitch/components/extended_video_player.js @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class ExtendedVideoPlayer extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + time: PropTypes.number, + controls: PropTypes.bool.isRequired, + muted: PropTypes.bool.isRequired, + onClick: PropTypes.func, + }; + + handleLoadedData = () => { + if (this.props.time) { + this.video.currentTime = this.props.time; + } + } + + componentDidMount () { + this.video.addEventListener('loadeddata', this.handleLoadedData); + } + + componentWillUnmount () { + this.video.removeEventListener('loadeddata', this.handleLoadedData); + } + + setRef = (c) => { + this.video = c; + } + + handleClick = e => { + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + + render () { + const { src, muted, controls, alt } = this.props; + + return ( + <div className='extended-video-player'> + <video + ref={this.setRef} + src={src} + autoPlay + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + muted={muted} + controls={controls} + loop={!controls} + onClick={this.handleClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js new file mode 100644 index 000000000..d75edd994 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hashtag.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from './permalink'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; + +const Hashtag = ({ hashtag }) => ( + <div className='trends__item'> + <div className='trends__item__name'> + <Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}> + #<span>{hashtag.get('name')}</span> + </Permalink> + + <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} /> + </div> + + <div className='trends__item__current'> + {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))} + </div> + + <div className='trends__item__sparkline'> + <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}> + <SparklinesCurve style={{ fill: 'none' }} /> + </Sparklines> + </div> + </div> +); + +Hashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +export default Hashtag; diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js new file mode 100644 index 000000000..8f55a0115 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon.js @@ -0,0 +1,26 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// This just renders a FontAwesome icon. +export default function Icon ({ + className, + fullwidth, + icon, +}) { + const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className); + return icon ? ( + <span + aria-hidden='true' + className={computedClass} + /> + ) : null; +} + +// Props. +Icon.propTypes = { + className: PropTypes.string, + fullwidth: PropTypes.bool, + icon: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js new file mode 100644 index 000000000..6a25794d3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -0,0 +1,139 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class IconButton extends React.PureComponent { + + static propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + onClick: PropTypes.func, + size: PropTypes.number, + active: PropTypes.bool, + pressed: PropTypes.bool, + expanded: PropTypes.bool, + style: PropTypes.object, + activeStyle: PropTypes.object, + disabled: PropTypes.bool, + inverted: PropTypes.bool, + animate: PropTypes.bool, + flip: PropTypes.bool, + overlay: PropTypes.bool, + tabIndex: PropTypes.string, + label: PropTypes.string, + }; + + static defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: '0', + }; + + handleClick = (e) => { + e.preventDefault(); + + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + render () { + let style = { + fontSize: `${this.props.size}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, + animate, + className, + disabled, + expanded, + icon, + inverted, + flip, + overlay, + pressed, + tabIndex, + title, + } = this.props; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + 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. + return ( + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + style={style} + tabIndex={tabIndex} + disabled={disabled} + > + <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> + </button> + ); + } + + return ( + <Motion defaultStyle={motionDefaultStyle} style={motionStyle}> + {({ rotate }) => + (<button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + style={style} + tabIndex={tabIndex} + disabled={disabled} + > + <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/flavours/glitch/components/intersection_observer_article.js b/app/javascript/flavours/glitch/components/intersection_observer_article.js new file mode 100644 index 000000000..900c98638 --- /dev/null +++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js @@ -0,0 +1,127 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; +import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry'; + +export default class IntersectionObserverArticle extends ImmutablePureComponent { + + static propTypes = { + intersectionObserverWrapper: PropTypes.object.isRequired, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + saveHeightKey: PropTypes.string, + cachedHeight: PropTypes.number, + onHeightChange: PropTypes.func, + children: PropTypes.node, + }; + + state = { + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + shouldComponentUpdate (nextProps, nextState) { + if (!nextState.isIntersecting && nextState.isHidden) { + // It's only if we're not intersecting (i.e. offscreen) and isHidden is true + // that either "isIntersecting" or "isHidden" matter, and then they're + // the only things that matter (and updated ARIA attributes). + return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; + } else if (nextState.isIntersecting && !this.state.isIntersecting) { + // If we're going from a non-intersecting state to an intersecting state, + // (i.e. offscreen to onscreen), then we definitely need to re-render + return true; + } + // Otherwise, diff based on "updateOnProps" and "updateOnStates" + return super.shouldComponentUpdate(nextProps, nextState); + } + + componentDidMount () { + const { intersectionObserverWrapper, id } = this.props; + + intersectionObserverWrapper.observe( + id, + this.node, + this.handleIntersection + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + const { intersectionObserverWrapper, id } = this.props; + intersectionObserverWrapper.unobserve(id, this.node); + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + this.entry = entry; + + scheduleIdleTask(this.calculateHeight); + this.setState(this.updateStateAfterIntersection); + } + + updateStateAfterIntersection = (prevState) => { + if (prevState.isIntersecting !== false && !this.entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: this.entry.isIntersecting, + isHidden: false, + }; + } + + calculateHeight = () => { + const { onHeightChange, saveHeightKey, id } = this.props; + // save the height of the fully-rendered element (this is expensive + // on Chrome, where we need to fall back to getBoundingClientRect) + this.height = getRectFromEntry(this.entry).height; + + if (onHeightChange && saveHeightKey) { + onHeightChange(saveHeightKey, id, this.height); + } + } + + hideIfNotIntersecting = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + render () { + const { children, id, index, listLength, cachedHeight } = this.props; + const { isIntersecting, isHidden } = this.state; + + const style = {}; + + if (!isIntersecting && (isHidden || cachedHeight)) { + style.height = `${this.height || cachedHeight || 150}px`; + style.opacity = 0; + style.overflow = 'hidden'; + } + + return ( + <article + ref={this.handleRef} + aria-posinset={index + 1} + aria-setsize={listLength} + data-id={id} + tabIndex='0' + style={style}> + {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || cachedHeight) })} + </article> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/link.js b/app/javascript/flavours/glitch/components/link.js new file mode 100644 index 000000000..de99f7d42 --- /dev/null +++ b/app/javascript/flavours/glitch/components/link.js @@ -0,0 +1,97 @@ +// Inspired by <CommonLink> from Mastodon GO! +// ~ 😘 kibi! + +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Utils. +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // We don't handle clicks that are made with modifiers, since these + // often have special browser meanings (eg, "open in new tab"). + click (e) { + const { onClick } = this.props; + if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { + return; + } + onClick(e); + e.preventDefault(); // Prevents following of the link + }, +}; + +// The component. +export default class Link extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { click } = this.handlers; + const { + children, + className, + href, + onClick, + role, + title, + ...rest + } = this.props; + const computedClass = classNames('link', className, `role-${role}`); + + // We assume that our `onClick` is a routing function and give it + // the qualities of a link even if no `href` is provided. However, + // if we have neither an `onClick` or an `href`, our link is + // purely presentational. + const conditionalProps = {}; + if (href) { + conditionalProps.href = href; + conditionalProps.onClick = click; + } else if (onClick) { + conditionalProps.onClick = click; + conditionalProps.role = 'link'; + conditionalProps.tabIndex = 0; + } else { + conditionalProps.role = 'presentation'; + } + + // If we were provided a `role` it overwrites any that we may have + // set above. This can be used for "links" which are actually + // buttons. + if (role) { + conditionalProps.role = role; + } + + // Rendering. We set `rel='noopener'` for user privacy, and our + // `target` as `'_blank'`. + return ( + <a + className={computedClass} + {...conditionalProps} + rel='noopener' + target='_blank' + title={title} + {...rest} + >{children}</a> + ); + } + +} + +// Props. +Link.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + href: PropTypes.string, // The link destination + onClick: PropTypes.func, // A function to call instead of opening the link + role: PropTypes.string, // An ARIA role for the link + title: PropTypes.string, // A title for the link +}; diff --git a/app/javascript/flavours/glitch/components/load_gap.js b/app/javascript/flavours/glitch/components/load_gap.js new file mode 100644 index 000000000..012303ae1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_gap.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +@injectIntl +export default class LoadGap extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + } + + render () { + const { disabled, intl } = this.props; + + return ( + <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}> + <i className='fa fa-ellipsis-h' /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js new file mode 100644 index 000000000..389c3e1e1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_more.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadMore extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + disabled: PropTypes.bool, + visible: PropTypes.bool, + } + + static defaultProps = { + visible: true, + } + + render() { + const { disabled, visible } = this.props; + + return ( + <button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> + <FormattedMessage id='status.load_more' defaultMessage='Load more' /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/loading_indicator.js b/app/javascript/flavours/glitch/components/loading_indicator.js new file mode 100644 index 000000000..d6a5adb6f --- /dev/null +++ b/app/javascript/flavours/glitch/components/loading_indicator.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const LoadingIndicator = () => ( + <div className='loading-indicator'> + <div className='loading-indicator__figure' /> + <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> + </div> +); + +export default LoadingIndicator; diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js new file mode 100644 index 000000000..194800d52 --- /dev/null +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -0,0 +1,373 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { is } from 'immutable'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from 'flavours/glitch/util/is_mobile'; +import classNames from 'classnames'; +import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; + +const messages = defineMessages({ + hidden: { + defaultMessage: 'Media hidden', + id: 'status.media_hidden', + }, + sensitive: { + defaultMessage: 'Sensitive', + id: 'media_gallery.sensitive', + }, + toggle: { + defaultMessage: 'Click to view', + id: 'status.sensitive_toggle', + }, + toggle_visible: { + defaultMessage: 'Toggle visibility', + id: 'media_gallery.toggle_visible', + }, + warning: { + defaultMessage: 'Sensitive content', + id: 'status.sensitive_warning', + }, +}); + +class Item extends React.PureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + standalone: PropTypes.bool, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + letterbox: PropTypes.bool, + onClick: PropTypes.func.isRequired, + displayWidth: PropTypes.number, + visible: PropTypes.bool.isRequired, + }; + + static defaultProps = { + standalone: false, + index: 0, + size: 1, + }; + + state = { + loaded: false, + }; + + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + const { attachment } = this.props; + return !autoPlayGif && attachment.get('type') === 'gifv'; + } + + handleClick = (e) => { + const { index, onClick } = this.props; + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + } + + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + + render () { + const { attachment, index, size, standalone, letterbox, displayWidth, visible } = 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') === 'unknown') { + return ( + <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}> + <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> + </a> + </div> + ); + } else 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 hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; + + const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; + const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null; + + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + > + <img + className={letterbox ? 'letterbox' : null} + src={previewUrl} + srcSet={srcSet} + sizes={sizes} + alt={attachment.get('description')} + title={attachment.get('description')} + style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + </a> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = !isIOS() && autoPlayGif; + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`} + aria-label={attachment.get('description')} + title={attachment.get('description')} + 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={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} /> + {visible && thumbnail} + </div> + ); + } + +} + +@injectIntl +export default class MediaGallery extends React.PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + revealed: PropTypes.bool, + standalone: PropTypes.bool, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + hidden: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + size: PropTypes.object, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + defaultWidth: PropTypes.number, + cacheWidth: PropTypes.func, + }; + + static defaultProps = { + standalone: false, + }; + + state = { + visible: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed, + width: this.props.defaultWidth, + }; + + componentWillReceiveProps (nextProps) { + if (!is(nextProps.media, this.props.media) || nextProps.revealed === true) { + this.setState({ visible: nextProps.revealed === undefined ? (displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all') : nextProps.revealed }); + } + } + + componentDidUpdate (prevProps) { + if (this.node && this.node.offsetWidth && this.node.offsetWidth != this.state.width) { + this.setState({ + width: this.node.offsetWidth, + }); + } + } + + handleOpen = () => { + this.setState({ visible: !this.state.visible }); + } + + handleClick = (index) => { + this.props.onOpenMedia(this.props.media, index); + } + + handleRef = (node) => { + this.node = node; + if (node && node.offsetWidth && node.offsetWidth != this.state.width) { + if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); + + this.setState({ + width: node.offsetWidth, + }); + } + } + + isStandaloneEligible() { + const { media, standalone } = this.props; + return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + } + + render () { + const { media, intl, sensitive, letterbox, fullwidth, defaultWidth } = this.props; + const { visible } = this.state; + const size = media.take(4).size; + + const width = this.state.width || defaultWidth; + + let children, spoilerButton; + + const style = {}; + + const computedClass = classNames('media-gallery', { 'full-width': fullwidth }); + + if (this.isStandaloneEligible() && width) { + style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); + } else if (width) { + style.height = width / (16/9); + } else { + return (<div className={computedClass} ref={this.handleRef}></div>); + } + + if (this.isStandaloneEligible()) { + children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />); + } + + if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />; + } else { + spoilerButton = ( + <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span> + </button> + ); + } + + return ( + <div className={computedClass} style={style} ref={this.handleRef}> + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}> + {spoilerButton} + {visible && sensitive && ( + <span className='sensitive-marker'> + <FormattedMessage {...messages.sensitive} /> + </span> + )} + </div> + + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/missing_indicator.js b/app/javascript/flavours/glitch/components/missing_indicator.js new file mode 100644 index 000000000..70d8c3b98 --- /dev/null +++ b/app/javascript/flavours/glitch/components/missing_indicator.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const MissingIndicator = () => ( + <div className='regeneration-indicator missing-indicator'> + <div> + <div className='regeneration-indicator__figure' /> + + <div className='regeneration-indicator__label'> + <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> + <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> + </div> + </div> + </div> +); + +export default MissingIndicator; diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js new file mode 100644 index 000000000..4e8648b49 --- /dev/null +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -0,0 +1,112 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createHistory from 'history/createBrowserHistory'; + +export default class ModalRoot extends React.PureComponent { + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + children: PropTypes.node, + onClose: PropTypes.func.isRequired, + noEsc: PropTypes.bool, + }; + + state = { + revealed: !!this.props.children, + }; + + activeElement = this.state.revealed ? document.activeElement : null; + + handleKeyUp = (e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.children && !this.props.noEsc) { + this.props.onClose(); + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + this.history = this.context.router ? this.context.router.history : createHistory(); + } + + componentWillReceiveProps (nextProps) { + if (!!nextProps.children && !this.props.children) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } else if (!nextProps.children) { + this.setState({ revealed: false }); + } + if (!nextProps.children && !!this.props.children) { + this.activeElement.focus({ preventScroll: true }); + this.activeElement = null; + } + } + + componentDidUpdate (prevProps) { + if (!this.props.children && !!prevProps.children) { + this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + this.handleModalClose(); + } + if (this.props.children) { + requestAnimationFrame(() => { + this.setState({ revealed: true }); + }); + if (!prevProps.children) this.handleModalOpen(); + } + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + handleModalClose () { + this.unlistenHistory(); + + const state = this.history.location.state; + if (state && state.mastodonModalOpen) { + this.history.goBack(); + } + } + + handleModalOpen () { + const history = this.history; + const state = {...history.location.state, mastodonModalOpen: true}; + history.push(history.location.pathname, state); + this.unlistenHistory = history.listen(() => { + this.props.onClose(); + }); + } + + getSiblings = () => { + return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); + } + + setRef = ref => { + this.node = ref; + } + + render () { + const { children, onClose } = this.props; + const { revealed } = this.state; + const visible = !!children; + + if (!visible) { + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> + ); + } + + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}> + <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> + <div role='presentation' className='modal-root__overlay' onClick={onClose} /> + <div role='dialog' className='modal-root__container'>{children}</div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/notification_purge_buttons.js b/app/javascript/flavours/glitch/components/notification_purge_buttons.js new file mode 100644 index 000000000..e0c1543b0 --- /dev/null +++ b/app/javascript/flavours/glitch/components/notification_purge_buttons.js @@ -0,0 +1,58 @@ +/** + * 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'; + +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/flavours/glitch/components/permalink.js b/app/javascript/flavours/glitch/components/permalink.js new file mode 100644 index 000000000..718b02115 --- /dev/null +++ b/app/javascript/flavours/glitch/components/permalink.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class Permalink extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + children: PropTypes.node, + onInterceptClick: PropTypes.func, + }; + + handleClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.props.onInterceptClick && this.props.onInterceptClick()) { + e.preventDefault(); + return; + } + + if (this.context.router) { + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(this.props.to, state); + } + } + } + + render () { + const { + children, + className, + href, + to, + onInterceptClick, + ...other + } = this.props; + + return ( + <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> + {children} + </a> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js new file mode 100644 index 000000000..690f9ae5a --- /dev/null +++ b/app/javascript/flavours/glitch/components/poll.js @@ -0,0 +1,140 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { vote, fetchPoll } from 'mastodon/actions/polls'; +import Motion from 'mastodon/features/ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'mastodon/features/emoji/emoji'; +import RelativeTimestamp from './relative_timestamp'; + +const messages = defineMessages({ + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, +}); + +const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { + obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); + return obj; +}, {}); + +export default @injectIntl +class Poll extends ImmutablePureComponent { + + static propTypes = { + poll: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func, + disabled: PropTypes.bool, + }; + + state = { + selected: {}, + }; + + handleOptionChange = e => { + const { target: { value } } = e; + + if (this.props.poll.get('multiple')) { + const tmp = { ...this.state.selected }; + if (tmp[value]) { + delete tmp[value]; + } else { + tmp[value] = true; + } + this.setState({ selected: tmp }); + } else { + const tmp = {}; + tmp[value] = true; + this.setState({ selected: tmp }); + } + }; + + handleVote = () => { + if (this.props.disabled) { + return; + } + + this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); + }; + + handleRefresh = () => { + if (this.props.disabled) { + return; + } + + this.props.dispatch(fetchPoll(this.props.poll.get('id'))); + }; + + renderOption (option, optionIndex) { + const { poll, disabled } = this.props; + const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100; + const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); + const active = !!this.state.selected[`${optionIndex}`]; + const showResults = poll.get('voted') || poll.get('expired'); + + let titleEmojified = option.get('title_emojified'); + if (!titleEmojified) { + const emojiMap = makeEmojiMap(poll); + titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); + } + + return ( + <li key={option.get('title')}> + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + + <label className={classNames('poll__text', { selectable: !showResults })}> + <input + name='vote-options' + type={poll.get('multiple') ? 'checkbox' : 'radio'} + value={optionIndex} + checked={active} + onChange={this.handleOptionChange} + disabled={disabled} + /> + + {!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />} + {showResults && <span className='poll__number'>{Math.round(percent)}%</span>} + + <span dangerouslySetInnerHTML={{ __html: titleEmojified }} /> + </label> + </li> + ); + } + + render () { + const { poll, intl } = this.props; + + if (!poll) { + return null; + } + + const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; + const showResults = poll.get('voted') || poll.get('expired'); + const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i))} + </ul> + + <div className='poll__footer'> + {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} + {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} + <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> + {poll.get('expires_at') && <span> · {timeRemaining}</span>} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js new file mode 100644 index 000000000..aa4b73cfe --- /dev/null +++ b/app/javascript/flavours/glitch/components/relative_timestamp.js @@ -0,0 +1,186 @@ +import React from 'react'; +import { injectIntl, defineMessages } from 'react-intl'; +import PropTypes from 'prop-types'; + +const messages = defineMessages({ + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, + moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, + seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, + minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, + hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, + days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, +}); + +const dateFormatOptions = { + hour12: false, + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', +}; + +const shortDateFormatOptions = { + month: 'short', + day: 'numeric', +}; + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const MAX_DELAY = 2147483647; + +const selectUnits = delta => { + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return 'second'; + } else if (absDelta < HOUR) { + return 'minute'; + } else if (absDelta < DAY) { + return 'hour'; + } + + return 'day'; +}; + +const getUnitDelay = units => { + switch (units) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + default: + return MAX_DELAY; + } +}; + +export const timeAgoString = (intl, date, now, year) => { + const delta = now - date.getTime(); + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.just_now); + } else if (delta < 7 * DAY) { + if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); + } + } else if (date.getFullYear() === year) { + relativeTime = intl.formatDate(date, shortDateFormatOptions); + } else { + relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' }); + } + + return relativeTime; +}; + +const timeRemainingString = (intl, date, now) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.moments_remaining); + } else if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) }); + } + + return relativeTime; +}; + +export default @injectIntl +class RelativeTimestamp extends React.Component { + + static propTypes = { + intl: PropTypes.object.isRequired, + timestamp: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + futureDate: PropTypes.bool, + }; + + state = { + now: this.props.intl.now(), + }; + + static defaultProps = { + year: (new Date()).getFullYear(), + }; + + shouldComponentUpdate (nextProps, nextState) { + // As of right now the locale doesn't change without a new page load, + // but we might as well check in case that ever changes. + return this.props.timestamp !== nextProps.timestamp || + this.props.intl.locale !== nextProps.intl.locale || + this.state.now !== nextState.now; + } + + componentWillReceiveProps (nextProps) { + if (this.props.timestamp !== nextProps.timestamp) { + this.setState({ now: this.props.intl.now() }); + } + } + + componentDidMount () { + this._scheduleNextUpdate(this.props, this.state); + } + + componentWillUpdate (nextProps, nextState) { + this._scheduleNextUpdate(nextProps, nextState); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _scheduleNextUpdate (props, state) { + clearTimeout(this._timer); + + const { timestamp } = props; + const delta = (new Date(timestamp)).getTime() - state.now; + const unitDelay = getUnitDelay(selectUnits(delta)); + const unitRemainder = Math.abs(delta % unitDelay); + const updateInterval = 1000 * 10; + const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); + + this._timer = setTimeout(() => { + this.setState({ now: this.props.intl.now() }); + }, delay); + } + + render () { + const { timestamp, intl, year, futureDate } = this.props; + + const date = new Date(timestamp); + const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year); + + return ( + <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> + {relativeTime} + </time> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js new file mode 100644 index 000000000..462185bbc --- /dev/null +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -0,0 +1,296 @@ +import React, { PureComponent } from 'react'; +import { ScrollContainer } from 'react-router-scroll-4'; +import PropTypes from 'prop-types'; +import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container'; +import LoadMore from './load_more'; +import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; +import { List as ImmutableList } from 'immutable'; +import classNames from 'classnames'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; +import LoadingIndicator from './loading_indicator'; + +const MOUSE_IDLE_DELAY = 300; + +export default class ScrollableList extends PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onLoadMore: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + showLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + alwaysPrepend: PropTypes.bool, + emptyMessage: PropTypes.node, + children: PropTypes.node, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + fullscreen: null, + cachedMediaWidth: 300, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + + if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) { + this.props.onLoadMore(); + } + + if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + + if (!this.lastScrollWasSynthetic) { + // If the last scroll wasn't caused by setScrollTop(), assume it was + // intentional and cancel any pending scroll reset on mouse idle + this.scrollToTopOnMouseIdle = false; + } + this.lastScrollWasSynthetic = false; + } + }, 150, { + trailing: true, + }); + + mouseIdleTimer = null; + mouseMovedRecently = false; + lastScrollWasSynthetic = false; + scrollToTopOnMouseIdle = false; + + setScrollTop = newScrollTop => { + if (this.node.scrollTop !== newScrollTop) { + this.lastScrollWasSynthetic = true; + this.node.scrollTop = newScrollTop; + } + }; + + clearMouseIdleTimer = () => { + if (this.mouseIdleTimer === null) { + return; + } + clearTimeout(this.mouseIdleTimer); + this.mouseIdleTimer = null; + }; + + handleMouseMove = throttle(() => { + // As long as the mouse keeps moving, clear and restart the idle timer. + this.clearMouseIdleTimer(); + this.mouseIdleTimer = + setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + + if (!this.mouseMovedRecently && this.node.scrollTop === 0) { + // Only set if we just started moving and are scrolled to the top. + this.scrollToTopOnMouseIdle = true; + } + // Save setting this flag for last, so we can do the comparison above. + this.mouseMovedRecently = true; + }, MOUSE_IDLE_DELAY / 2); + + handleWheel = throttle(() => { + this.scrollToTopOnMouseIdle = false; + }, 150, { + trailing: true, + }); + + handleMouseIdle = () => { + if (this.scrollToTopOnMouseIdle) { + this.setScrollTop(0); + } + this.mouseMovedRecently = false; + this.scrollToTopOnMouseIdle = false; + } + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + attachFullscreenListener(this.onFullScreenChange); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + getScrollPosition = () => { + if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { + return {height: this.node.scrollHeight, top: this.node.scrollTop}; + } else { + return null; + } + } + + updateScrollBottom = (snapshot) => { + const newScrollTop = this.node.scrollHeight - snapshot; + + this.setScrollTop(newScrollTop); + } + + cacheMediaWidth = (width) => { + if (width && this.state.cachedMediaWidth != width) this.setState({ cachedMediaWidth: width }); + } + + getSnapshotBeforeUpdate (prevProps, prevState) { + const someItemInserted = React.Children.count(prevProps.children) > 0 && + React.Children.count(prevProps.children) < React.Children.count(this.props.children) && + this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); + if (someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { + return this.node.scrollHeight - this.node.scrollTop; + } else { + return null; + } + } + + componentDidUpdate (prevProps, prevState, snapshot) { + // Reset the scroll position when a new child comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (snapshot !== null) this.updateScrollBottom(snapshot); + } + + componentWillUnmount () { + this.clearMouseIdleTimer(); + this.detachScrollListener(); + this.detachIntersectionObserver(); + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel); + } + + getFirstChildKey (props) { + const { children } = props; + let firstChild = children; + + if (children instanceof ImmutableList) { + firstChild = children.get(0); + } else if (Array.isArray(children)) { + firstChild = children[0]; + } + + return firstChild && firstChild.key; + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = e => { + e.preventDefault(); + this.props.onLoadMore(); + } + + defaultShouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + render () { + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { fullscreen } = this.state; + const childrenCount = React.Children.count(children); + + const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; + let scrollableArea = null; + + if (showLoading) { + scrollableArea = ( + <div className='scrollable scrollable--flex' ref={this.setRef}> + <div role='feed' className='item-list'> + {prepend} + </div> + + <div className='scrollable__append'> + <LoadingIndicator /> + </div> + </div> + ); + } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) { + scrollableArea = ( + <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> + <div role='feed' className='item-list'> + {prepend} + + {React.Children.map(this.props.children, (child, index) => ( + <IntersectionObserverArticleContainer + key={child.key} + id={child.key} + index={index} + listLength={childrenCount} + intersectionObserverWrapper={this.intersectionObserverWrapper} + saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} + > + {React.cloneElement(child, { + getScrollPosition: this.getScrollPosition, + updateScrollBottom: this.updateScrollBottom, + cachedMediaWidth: this.state.cachedMediaWidth, + cacheMediaWidth: this.cacheMediaWidth, + })} + </IntersectionObserverArticleContainer> + ))} + + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}> + {alwaysPrepend && prepend} + + <div className='empty-column-indicator'> + {emptyMessage} + </div> + </div> + ); + } + + if (trackScroll) { + return ( + <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll || this.defaultShouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/flavours/glitch/components/setting_text.js b/app/javascript/flavours/glitch/components/setting_text.js new file mode 100644 index 000000000..2c1b70bc3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/setting_text.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class SettingText extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = (e) => { + this.props.onChange(this.props.settingPath, e.target.value); + } + + render () { + const { settings, settingPath, label } = this.props; + + return ( + <label> + <span style={{ display: 'none' }}>{label}</span> + <input + className='setting-text' + value={settings.getIn(settingPath)} + onChange={this.handleChange} + placeholder={label} + /> + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js new file mode 100644 index 000000000..5f10e0c52 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status.js @@ -0,0 +1,643 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import StatusPrepend from './status_prepend'; +import StatusHeader from './status_header'; +import StatusIcons from './status_icons'; +import StatusContent from './status_content'; +import StatusActionBar from './status_action_bar'; +import AttachmentList from './attachment_list'; +import Card from '../features/status/components/card'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { MediaGallery, Video } from 'flavours/glitch/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; +import classNames from 'classnames'; +import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; +import PollContainer from 'flavours/glitch/containers/poll_container'; + +// We use the component (and not the container) since we do not want +// to use the progress bar to show download progress +import Bundle from '../features/ui/components/bundle'; + +export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => { + const displayName = status.getIn(['account', 'display_name']); + + const values = [ + displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, + status.get('spoiler_text') && !expanded ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), + intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + status.getIn(['account', 'acct']), + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + return values.join(', '); +}; + +@injectIntl +export default class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + containerId: PropTypes.string, + id: PropTypes.string, + status: ImmutablePropTypes.map, + account: ImmutablePropTypes.map, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onBookmark: PropTypes.func, + onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onPin: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onBlock: PropTypes.func, + onEmbed: PropTypes.func, + onHeightChange: PropTypes.func, + muted: PropTypes.bool, + collapse: PropTypes.bool, + hidden: PropTypes.bool, + prepend: PropTypes.string, + withDismiss: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + expanded: PropTypes.bool, + intl: PropTypes.object.isRequired, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + }; + + state = { + isCollapsed: false, + autoCollapsed: false, + isExpanded: undefined, + } + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'account', + 'settings', + 'prepend', + 'boostModal', + 'favouriteModal', + 'muted', + 'collapse', + 'notification', + 'hidden', + 'expanded', + ] + + updateOnStates = [ + 'isExpanded', + 'isCollapsed', + ] + + // 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 + // `getderivedStateFromProps()`. + + // 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. + static getDerivedStateFromProps(nextProps, prevState) { + let update = {}; + let updated = false; + + // Make sure the state mirrors props we track… + if (nextProps.collapse !== prevState.collapseProp) { + update.collapseProp = nextProps.collapse; + updated = true; + } + if (nextProps.expanded !== prevState.expandedProp) { + update.expandedProp = nextProps.expanded; + updated = true; + } + + // Update state based on new props + if (!nextProps.settings.getIn(['collapsed', 'enabled'])) { + if (prevState.isCollapsed) { + update.isCollapsed = false; + updated = true; + } + } else if ( + nextProps.collapse !== prevState.collapseProp && + nextProps.collapse !== undefined + ) { + update.isCollapsed = nextProps.collapse; + if (nextProps.collapse) update.isExpanded = false; + updated = true; + } + if (nextProps.expanded !== prevState.expandedProp && + nextProps.expanded !== undefined + ) { + update.isExpanded = nextProps.expanded; + if (nextProps.expanded) update.isCollapsed = false; + updated = true; + } + + if (nextProps.expanded === undefined && + prevState.isExpanded === undefined && + update.isExpanded === undefined + ) { + const isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status); + if (isExpanded !== undefined) { + update.isExpanded = isExpanded; + updated = true; + } + } + + return updated ? update : null; + } + + // 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 `setCollapsed()` 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. + // - The status is a reblog the user has decided to collapse all + // statuses which are reblogs. + componentDidMount () { + const { node } = this; + const { + status, + settings, + collapse, + muted, + prepend, + } = this.props; + + // Prevent a crash when node is undefined. Not completely sure why this + // happens, might be because status === null. + if (node === undefined) return; + + const autoCollapseSettings = settings.getIn(['collapsed', 'auto']); + + if (function () { + switch (true) { + case !!collapse: + case !!autoCollapseSettings.get('all'): + case autoCollapseSettings.get('notifications') && !!muted: + case autoCollapseSettings.get('lengthy') && node.clientHeight > ( + status.get('media_attachments').size && !muted ? 650 : 400 + ): + case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by': + case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null: + case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size: + return true; + default: + return false; + } + }()) { + this.setCollapsed(true); + // Hack to fix timeline jumps on second rendering when auto-collapsing + this.setState({ autoCollapsed: true }); + } + + this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'); + } + + getSnapshotBeforeUpdate (prevProps, prevState) { + if (this.props.getScrollPosition) { + return this.props.getScrollPosition(); + } else { + return null; + } + } + + // Hack to fix timeline jumps on second rendering when auto-collapsing + componentDidUpdate (prevProps, prevState, snapshot) { + const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'); + if (this.state.autoCollapsed || (doShowCard && !this.didShowCard)) { + if (doShowCard) this.didShowCard = true; + if (this.state.autoCollapsed) this.setState({ autoCollapsed: false }); + if (snapshot !== null && this.props.updateScrollBottom) { + if (this.node.offsetTop < snapshot.top) { + this.props.updateScrollBottom(snapshot.height - snapshot.top); + } + } + } + } + + componentWillUnmount() { + if (this.node && this.props.getScrollPosition) { + const position = this.props.getScrollPosition(); + if (position !== null && this.node.offsetTop < position.top) { + requestAnimationFrame(() => { this.props.updateScrollBottom(position.height - position.top); }); + } + } + } + + // `setCollapsed()` sets the value of `isCollapsed` in our state, that is, + // whether the toot is collapsed or not. + + // `setCollapsed()` automatically checks for us whether toot collapsing + // is enabled, so we don't have to. + setCollapsed = (value) => { + if (this.props.settings.getIn(['collapsed', 'enabled'])) { + this.setState({ isCollapsed: value }); + if (value) { + this.setExpansion(false); + } + } else { + this.setState({ isCollapsed: false }); + } + } + + setExpansion = (value) => { + this.setState({ isExpanded: value }); + if (value) { + this.setCollapsed(false); + } + } + + // `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 { isCollapsed } = this.state; + if (!router) return; + if (destination === undefined) { + destination = `/statuses/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (isCollapsed) this.setCollapsed(false); + else if (e.shiftKey) { + this.setCollapsed(true); + document.getSelection().removeAllRanges(); + } else { + let state = {...router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + router.history.push(destination, state); + } + e.preventDefault(); + } + } + + handleAccountClick = (e) => { + if (this.context.router && e.button === 0) { + const id = e.currentTarget.getAttribute('data-id'); + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${id}`, state); + } + } + + handleExpandedToggle = () => { + if (this.props.status.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + handleOpenVideo = (media, startTime) => { + this.props.onOpenVideo(media, startTime); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this.props.status, this.context.router.history); + } + + handleHotkeyFavourite = (e) => { + this.props.onFavourite(this.props.status, e); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this.props.status, e); + } + + handleHotkeyBookmark = e => { + this.props.onBookmark(this.props.status, e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state); + } + + handleHotkeyOpenProfile = () => { + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state); + } + + handleHotkeyMoveUp = e => { + this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured')); + } + + handleHotkeyMoveDown = e => { + this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured')); + } + + handleHotkeyCollapse = e => { + if (!this.props.settings.getIn(['collapsed', 'enabled'])) + return; + + this.setCollapsed(!this.state.isCollapsed); + } + + + handleRef = c => { + this.node = c; + } + + renderLoadingMediaGallery () { + return <div className='media_gallery' style={{ height: '110px' }} />; + } + + renderLoadingVideoPlayer () { + return <div className='media-spoiler-video' style={{ height: '110px' }} />; + } + + render () { + const { + handleRef, + parseClick, + setExpansion, + setCollapsed, + } = this; + const { router } = this.context; + const { + intl, + status, + account, + settings, + collapsed, + muted, + prepend, + intersectionObserverWrapper, + onOpenVideo, + onOpenMedia, + notification, + hidden, + featured, + ...other + } = this.props; + const { isExpanded, isCollapsed } = this.state; + let background = null; + let attachments = null; + let media = null; + let mediaIcon = null; + + if (status === null) { + return null; + } + + if (hidden) { + return ( + <div ref={this.handleRef}> + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {' '} + {status.get('content')} + </div> + ); + } + + if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const minHandlers = this.props.muted ? {} : { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + <HotKeys handlers={minHandlers}> + <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}> + <FormattedMessage id='status.filtered' defaultMessage='Filtered' /> + </div> + </HotKeys> + ); + } + + // 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. + // If a media file is of unknwon type or if the status is muted + // (notification), we show a list of links instead of embedded media. + + // 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 (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + mediaIcon = 'tasks'; + } else if (attachments.size > 0) { + if (muted || attachments.some(item => item.get('type') === 'unknown')) { + media = ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + ); + } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video' + const video = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => (<Component + preview={video.get('preview_url')} + blurhash={video.get('blurhash')} + src={video.get('url')} + alt={video.get('description')} + inline + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + preventPlayback={isCollapsed || !isExpanded} + onOpenVideo={this.handleOpenVideo} + width={this.props.cachedMediaWidth} + cacheWidth={this.props.cacheMediaWidth} + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + />)} + </Bundle> + ); + mediaIcon = 'video-camera'; + } else { // Media type is 'image' or 'gifv' + media = ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> + {Component => ( + <Component + media={attachments} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + hidden={isCollapsed || !isExpanded} + onOpenMedia={this.props.onOpenMedia} + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + /> + )} + </Bundle> + ); + 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']); + } + } else if (status.get('card') && settings.get('inline_preview_cards')) { + media = ( + <Card + onOpenMedia={this.props.onOpenMedia} + card={status.get('card')} + compact + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + /> + ); + mediaIcon = 'link'; + } + + // 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')}`; + } + + let rebloggedByText; + + if (prepend === 'reblog') { + rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') }); + } + + const handlers = { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleSpoiler: this.handleExpandedToggle, + bookmark: this.handleHotkeyBookmark, + toggleCollapse: this.handleHotkeyCollapse, + }; + + const computedClass = classNames('status', `status-${status.get('visibility')}`, { + collapsed: isCollapsed, + 'has-background': isCollapsed && background, + 'status__wrapper-reply': !!status.get('in_reply_to_id'), + muted, + }, 'focusable'); + + return ( + <HotKeys handlers={handlers}> + <div + className={computedClass} + style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null} + {...selectorAttribs} + ref={handleRef} + tabIndex='0' + data-featured={featured ? 'true' : null} + aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} + > + <header className='status__info'> + <span> + {prepend && account ? ( + <StatusPrepend + type={prepend} + account={account} + parseClick={parseClick} + notificationId={this.props.notificationId} + /> + ) : null} + {!muted || !isCollapsed ? ( + <StatusHeader + status={status} + friend={account} + collapsed={isCollapsed} + parseClick={parseClick} + /> + ) : null} + </span> + <StatusIcons + status={status} + mediaIcon={mediaIcon} + collapsible={settings.getIn(['collapsed', 'enabled'])} + collapsed={isCollapsed} + setCollapsed={setCollapsed} + /> + </header> + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + expanded={isExpanded} + onExpandedToggle={this.handleExpandedToggle} + parseClick={parseClick} + disabled={!router} + /> + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( + <StatusActionBar + {...other} + status={status} + account={status.get('account')} + showReplyCount={settings.get('show_reply_count')} + /> + ) : null} + {notification ? ( + <NotificationOverlayContainer + notification={notification} + /> + ) : null} + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js new file mode 100644 index 000000000..6d1f54c60 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -0,0 +1,295 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me, isStaff } from 'flavours/glitch/util/initial_state'; +import RelativeTimestamp from './relative_timestamp'; +import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + 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' }, + more: { id: 'status.more', defaultMessage: 'More' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + 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' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, + copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, +}); + +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +@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, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: PropTypes.func, + onReport: PropTypes.func, + onEmbed: PropTypes.func, + onMuteConversation: PropTypes.func, + onPin: PropTypes.func, + onBookmark: PropTypes.func, + withDismiss: PropTypes.bool, + showReplyCount: 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', + 'showReplyCount', + 'withDismiss', + ] + + handleReplyClick = () => { + if (me) { + this.props.onReply(this.props.status, this.context.router.history); + } else { + this._openInteractionDialog('reply'); + } + } + + handleShareClick = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleFavouriteClick = (e) => { + if (me) { + this.props.onFavourite(this.props.status, e); + } else { + this._openInteractionDialog('favourite'); + } + } + + handleBookmarkClick = (e) => { + this.props.onBookmark(this.props.status, e); + } + + handleReblogClick = e => { + if (me) { + this.props.onReblog(this.props.status, e); + } else { + this._openInteractionDialog('reblog'); + } + } + + _openInteractionDialog = type => { + window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status, this.context.router.history); + } + + handleRedraftClick = () => { + this.props.onDelete(this.props.status, this.context.router.history, true); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleDirectClick = () => { + this.props.onDirect(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); + } + + handleOpen = () => { + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state); + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + handleCopy = () => { + const url = this.props.status.get('url'); + const textarea = document.createElement('textarea'); + + textarea.textContent = url; + textarea.style.position = 'fixed'; + + document.body.appendChild(textarea); + + try { + textarea.select(); + document.execCommand('copy'); + } catch (e) { + + } finally { + document.body.removeChild(textarea); + } + } + + render () { + const { status, intl, withDismiss, showReplyCount } = this.props; + + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const reblogDisabled = status.get('visibility') === 'direct' || (status.get('visibility') === 'private' && me !== status.getIn(['account', 'id'])); + const reblogMessage = status.get('visibility') === 'private' ? messages.reblog_private : messages.reblog; + + 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.copy), action: this.handleCopy }); + 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 }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + 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 (isStaff && (accountAdminLink || statusAdminLink)) { + menu.push(null); + if (accountAdminLink !== undefined) { + menu.push({ + text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), + href: accountAdminLink(status.getIn(['account', 'id'])), + }); + } + if (statusAdminLink !== undefined) { + menu.push({ + text: intl.formatMessage(messages.admin_status), + href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')), + }); + } + } + } + + 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} /> + ); + + let replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + /> + ); + if (showReplyCount) { + replyButton = ( + <div className='status__action-bar__counter'> + {replyButton} + <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span> + </div> + ); + } + + return ( + <div className='status__action-bar'> + {replyButton} + <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + {shareButton} + <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /> + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.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/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js new file mode 100644 index 000000000..07a0d1d5d --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -0,0 +1,257 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import { FormattedMessage } from 'react-intl'; +import Permalink from './permalink'; +import classnames from 'classnames'; + +export default class StatusContent extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.bool, + collapsed: PropTypes.bool, + onExpandedToggle: PropTypes.func, + media: PropTypes.element, + mediaIcon: PropTypes.string, + parseClick: PropTypes.func, + disabled: PropTypes.bool, + onUpdate: PropTypes.func, + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.node; + + if (!node) { + return; + } + + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + if (link.classList.contains('status-link')) { + continue; + } + link.classList.add('status-link'); + + 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'); + } + } + + componentDidMount () { + this._updateStatusLinks(); + } + + componentDidUpdate () { + this._updateStatusLinks(); + if (this.props.onUpdate) this.props.onUpdate(); + } + + onLinkClick = (e) => { + if (this.props.collapsed) { + 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, disabled } = this.props; + + if (disabled || !this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + let element = e.target; + while (element) { + if (element.localName === 'button' || element.localName === 'video' || element.localName === 'a' || element.localName === 'label') { + return; + } + element = element.parentNode; + } + + if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { + parseClick(e); + } + + this.startXY = null; + } + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.onExpandedToggle) { + this.props.onExpandedToggle(); + } 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.onExpandedToggle ? !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, + 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + }); + + 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} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <p + style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} + > + <span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} /> + {' '} + <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} + tabIndex={!hidden ? 0 : null} + dangerouslySetInnerHTML={content} + className='status__content__text' + lang={status.get('language')} + /> + {media} + </div> + + </div> + ); + } else if (parseClick) { + return ( + <div + className={classNames} + style={directionStyle} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + tabIndex='0' + > + <div + ref={this.setRef} + dangerouslySetInnerHTML={content} + lang={status.get('language')} + className='status__content__text' + tabIndex='0' + /> + {media} + </div> + ); + } else { + return ( + <div + className='status__content' + style={directionStyle} + tabIndex='0' + > + <div ref={this.setRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' /> + {media} + </div> + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js new file mode 100644 index 000000000..f9321904c --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_header.js @@ -0,0 +1,62 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Mastodon imports. +import Avatar from './avatar'; +import AvatarOverlay from './avatar_overlay'; +import DisplayName from './display_name'; + +export default class StatusHeader extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map, + parseClick: PropTypes.func.isRequired, + }; + + // 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, + } = this.props; + + const account = status.get('account'); + + return ( + <div className='status__info__account' > + <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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js new file mode 100644 index 000000000..c9747650f --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_icons.js @@ -0,0 +1,83 @@ +// 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 IconButton from './icon_button'; +import VisibilityIcon from './status_visibility_icon'; + +// Messages for use with internationalization stuff. +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, +}); + +@injectIntl +export default class StatusIcons extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + mediaIcon: PropTypes.string, + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + setCollapsed: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + // Handles clicks on collapsed button + handleCollapsedClick = (e) => { + const { collapsed, setCollapsed } = this.props; + if (e.button === 0) { + setCollapsed(!collapsed); + e.preventDefault(); + } + } + + // Rendering. + render () { + const { + status, + mediaIcon, + collapsible, + collapsed, + intl, + } = this.props; + + return ( + <div className='status__info__icons'> + {status.get('in_reply_to_id', null) !== null ? ( + <i + className={`fa fa-fw fa-comment status__reply-icon`} + aria-hidden='true' + /> + ) : null} + {mediaIcon ? ( + <i + className={`fa fa-fw fa-${mediaIcon} status__media-icon`} + 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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js new file mode 100644 index 000000000..c1f51b307 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_list.js @@ -0,0 +1,137 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import LoadGap from './load_gap'; +import ScrollableList from './scrollable_list'; +import { FormattedMessage } from 'react-intl'; + +export default class StatusList extends ImmutablePureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + featuredStatusIds: ImmutablePropTypes.list, + onLoadMore: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + isPartial: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + alwaysPrepend: PropTypes.bool, + emptyMessage: PropTypes.node, + timelineId: PropTypes.string.isRequired, + }; + + static defaultProps = { + trackScroll: true, + }; + + getFeaturedStatusCount = () => { + return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; + } + + getCurrentStatusIndex = (id, featured) => { + if (featured) { + return this.props.featuredStatusIds.indexOf(id); + } else { + return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount(); + } + } + + handleMoveUp = (id, featured) => { + const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; + this._selectChild(elementIndex, true); + } + + handleMoveDown = (id, featured) => { + const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; + this._selectChild(elementIndex, false); + } + + handleLoadOlder = debounce(() => { + this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined); + }, 300, { leading: true }) + + _selectChild (index, align_top) { + const container = this.node.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } + element.focus(); + } + } + + setRef = c => { + this.node = c; + } + + render () { + const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props; + const { isLoading, isPartial } = other; + + if (isPartial) { + return ( + <div className='regeneration-indicator'> + <div> + <div className='regeneration-indicator__figure' /> + + <div className='regeneration-indicator__label'> + <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> + <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> + </div> + </div> + </div> + ); + } + + let scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map((statusId, index) => statusId === null ? ( + <LoadGap + key={'gap:' + statusIds.get(index + 1)} + disabled={isLoading} + maxId={index > 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ) : ( + <StatusContainer + key={statusId} + id={statusId} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + /> + )) + ) : null; + + if (scrollableContent && featuredStatusIds) { + scrollableContent = featuredStatusIds.map(statusId => ( + <StatusContainer + key={`f-${statusId}`} + id={statusId} + featured + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + /> + )).concat(scrollableContent); + } + + return ( + <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> + {scrollableContent} + </ScrollableList> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js new file mode 100644 index 000000000..481e6644e --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -0,0 +1,94 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; + +export default class StatusPrepend extends React.PureComponent { + + static propTypes = { + type: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + parseClick: PropTypes.func.isRequired, + notificationId: PropTypes.number, + }; + + handleClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/accounts/${account.get('id')}`); + } + + 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 'featured': + return ( + <FormattedMessage id='status.pinned' defaultMessage='Pinned toot' /> + ); + 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 }} + /> + ); + case 'poll': + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); + } + return null; + } + + render () { + const { Message } = this; + const { type } = this.props; + + return !type ? null : ( + <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}> + <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> + <i + className={`fa fa-fw fa-${ + type === 'favourite' ? 'star star-icon' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet')) + } status__prepend-icon`} + /> + </div> + <Message /> + </aside> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_visibility_icon.js b/app/javascript/flavours/glitch/components/status_visibility_icon.js new file mode 100644 index 000000000..5e7b8ed00 --- /dev/null +++ b/app/javascript/flavours/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', + 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/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js new file mode 100644 index 000000000..9c8ffab1f --- /dev/null +++ b/app/javascript/flavours/glitch/components/text_icon_button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class TextIconButton extends React.PureComponent { + + static propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string, + }; + + handleClick = (e) => { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + {label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/account_container.js b/app/javascript/flavours/glitch/containers/account_container.js new file mode 100644 index 000000000..bc84d299b --- /dev/null +++ b/app/javascript/flavours/glitch/containers/account_container.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Account from 'flavours/glitch/components/account'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount, +} from 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { unfollowModal } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + + + onMuteNotifications (account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/flavours/glitch/containers/compose_container.js b/app/javascript/flavours/glitch/containers/compose_container.js new file mode 100644 index 000000000..74c411b7c --- /dev/null +++ b/app/javascript/flavours/glitch/containers/compose_container.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'flavours/glitch/store/configureStore'; +import { hydrateStore } from 'flavours/glitch/actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import Compose from 'flavours/glitch/features/standalone/compose'; +import initialState from 'flavours/glitch/util/initial_state'; +import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); + +if (initialState) { + store.dispatch(hydrateStore(initialState)); +} + +store.dispatch(fetchCustomEmojis()); + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <Compose /> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/domain_container.js b/app/javascript/flavours/glitch/containers/domain_container.js new file mode 100644 index 000000000..52d5c1613 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/domain_container.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { blockDomain, unblockDomain } from '../actions/domain_blocks'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Domain from '../components/domain'; +import { openModal } from '../actions/modal'; + +const messages = defineMessages({ + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, +}); + +const makeMapStateToProps = () => { + const mapStateToProps = (state, { }) => ({ + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onBlockDomain (domain) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain)); diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js new file mode 100644 index 000000000..b2419a0fd --- /dev/null +++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js @@ -0,0 +1,33 @@ +import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu'; +import { openModal, closeModal } from 'flavours/glitch/actions/modal'; +import { connect } from 'react-redux'; +import DropdownMenu from 'flavours/glitch/components/dropdown_menu'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; + +const mapStateToProps = state => ({ + isModalOpen: state.get('modal').modalType === 'ACTIONS', + dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), + openDropdownId: state.getIn(['dropdown_menu', 'openId']), + openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), +}); + +const mapDispatchToProps = (dispatch, { status, items }) => ({ + onOpen(id, onItemClick, dropdownPlacement, keyboard) { + dispatch(isUserTouching() ? openModal('ACTIONS', { + status, + actions: items.map( + (item, i) => item ? { + ...item, + name: `${item.text}-${i}`, + onClick: item.action ? ((e) => { return onItemClick(i, e) }) : null, + } : null + ), + }) : openDropdownMenu(id, dropdownPlacement, keyboard)); + }, + onClose(id) { + dispatch(closeModal()); + dispatch(closeDropdownMenu(id)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js b/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js new file mode 100644 index 000000000..f2741f2d4 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import IntersectionObserverArticle from 'flavours/glitch/components/intersection_observer_article'; +import { setHeight } from 'flavours/glitch/actions/height_cache'; + +const makeMapStateToProps = (state, props) => ({ + cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), +}); + +const mapDispatchToProps = (dispatch) => ({ + + onHeightChange (key, id, height) { + dispatch(setHeight(key, id, height)); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js new file mode 100644 index 000000000..59eef6636 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/mastodon.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'flavours/glitch/store/configureStore'; +import { showOnboardingOnce } from 'flavours/glitch/actions/onboarding'; +import { BrowserRouter, Route } from 'react-router-dom'; +import { ScrollContext } from 'react-router-scroll-4'; +import UI from 'flavours/glitch/features/ui'; +import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; +import { hydrateStore } from 'flavours/glitch/actions/store'; +import { connectUserStream } from 'flavours/glitch/actions/streaming'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'locales'; +import initialState from 'flavours/glitch/util/initial_state'; +import ErrorBoundary from 'flavours/glitch/components/error_boundary'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export const store = configureStore(); +const hydrateAction = hydrateStore(initialState); +store.dispatch(hydrateAction); + +// load custom emojis +store.dispatch(fetchCustomEmojis()); + +export default class Mastodon extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + componentDidMount() { + this.disconnect = store.dispatch(connectUserStream()); + + // Desktop notifications + // Ask after 1 minute + if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { + window.setTimeout(() => Notification.requestPermission(), 60 * 1000); + } + + store.dispatch(showOnboardingOnce()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <ErrorBoundary> + <BrowserRouter basename='/web'> + <ScrollContext> + <Route path='/' component={UI} /> + </ScrollContext> + </BrowserRouter> + </ErrorBoundary> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js new file mode 100644 index 000000000..1b480658f --- /dev/null +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -0,0 +1,92 @@ +import React, { PureComponent, Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import MediaGallery from 'flavours/glitch/components/media_gallery'; +import Video from 'flavours/glitch/features/video'; +import Card from 'flavours/glitch/features/status/components/card'; +import Poll from 'flavours/glitch/components/poll'; +import ModalRoot from 'flavours/glitch/components/modal_root'; +import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; +import { List as ImmutableList, fromJS } from 'immutable'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; + +export default class MediaContainer extends PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + components: PropTypes.object.isRequired, + }; + + state = { + media: null, + index: null, + time: null, + }; + + handleOpenMedia = (media, index) => { + document.body.classList.add('with-modals--active'); + this.setState({ media, index }); + } + + handleOpenVideo = (video, time) => { + const media = ImmutableList([video]); + + document.body.classList.add('with-modals--active'); + this.setState({ media, time }); + } + + handleCloseMedia = () => { + document.body.classList.remove('with-modals--active'); + this.setState({ media: null, index: null, time: null }); + } + + render () { + const { locale, components } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Fragment> + {[].map.call(components, (component, i) => { + const componentName = component.getAttribute('data-component'); + const Component = MEDIA_COMPONENTS[componentName]; + const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props')); + + Object.assign(props, { + ...(media ? { media: fromJS(media) } : {}), + ...(card ? { card: fromJS(card) } : {}), + ...(poll ? { poll: fromJS(poll) } : {}), + + ...(componentName === 'Video' ? { + onOpenVideo: this.handleOpenVideo, + } : { + onOpenMedia: this.handleOpenMedia, + }), + }); + + return ReactDOM.createPortal( + <Component {...props} key={`media-${i}`} />, + component, + ); + })} + <ModalRoot onClose={this.handleCloseMedia}> + {this.state.media && ( + <MediaModal + media={this.state.media} + index={this.state.index || 0} + time={this.state.time} + onClose={this.handleCloseMedia} + /> + )} + </ModalRoot> + </Fragment> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js b/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js new file mode 100644 index 000000000..2570cf4a5 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js @@ -0,0 +1,49 @@ +// Package imports. +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Our imports. +import NotificationPurgeButtons from 'flavours/glitch/components/notification_purge_buttons'; +import { + deleteMarkedNotifications, + enterNotificationClearingMode, + markAllNotifications, +} from 'flavours/glitch/actions/notifications'; +import { openModal } from 'flavours/glitch/actions/modal'; + +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/flavours/glitch/containers/poll_container.js b/app/javascript/flavours/glitch/containers/poll_container.js new file mode 100644 index 000000000..cd7216de7 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/poll_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Poll from 'mastodon/components/poll'; + +const mapStateToProps = (state, { pollId }) => ({ + poll: state.getIn(['polls', pollId]), +}); + +export default connect(mapStateToProps)(Poll); diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js new file mode 100644 index 000000000..60636feb4 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -0,0 +1,205 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Status from 'flavours/glitch/components/status'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { + replyCompose, + mentionCompose, + directCompose, +} from 'flavours/glitch/actions/compose'; +import { + reblog, + favourite, + bookmark, + unreblog, + unfavourite, + unbookmark, + pin, + unpin, +} from 'flavours/glitch/actions/interactions'; +import { blockAccount } from 'flavours/glitch/actions/accounts'; +import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; +import { showAlertForError } from '../actions/alerts'; + +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?' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => { + + let status = getStatus(state, props); + let reblogStatus = status ? status.get('reblog', null) : null; + let account = undefined; + let prepend = undefined; + + if (props.featured) { + account = status.get('account'); + prepend = 'featured'; + } else if (reblogStatus !== null && typeof reblogStatus === 'object') { + account = status.get('account'); + status = reblogStatus; + prepend = 'reblogged_by'; + } + + return { + containerId : props.containerId || props.id, // Should match reblogStatus's id for reblogs + status : status, + account : account || props.account, + settings : state.get('local_settings'), + prepend : prepend || props.prepend, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch((_, getState) => { + let state = getState(); + if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), + onConfirm: () => dispatch(replyCompose(status, router)), + })); + } else { + dispatch(replyCompose(status, router)); + } + }); + }, + + onModalReblog (status) { + dispatch(reblog(status)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !boostModal) { + this.onModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); + } + } + }, + + onBookmark (status) { + if (status.get('bookmarked')) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }, + + onModalFavourite (status) { + dispatch(favourite(status)); + }, + + onFavourite (status, e) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + if (e.shiftKey || !favouriteModal) { + this.onModalFavourite(status); + } else { + dispatch(openModal('FAVOURITE', { status, onFavourite: this.onModalFavourite })); + } + } + }, + + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { + url: status.get('url'), + onError: error => dispatch(showAlertForError(error)), + })); + }, + + onDelete (status, history, withRedraft = false) { + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'), history, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), + })); + } + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (status) { + const account = status.get('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'))), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account, status)); + }, + })); + }, + + 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/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js new file mode 100644 index 000000000..6ce556664 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/timeline_container.js @@ -0,0 +1,62 @@ +import React, { Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'flavours/glitch/store/configureStore'; +import { hydrateStore } from 'flavours/glitch/actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline'; +import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline'; +import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container'; +import initialState from 'flavours/glitch/util/initial_state'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); + +if (initialState) { + store.dispatch(hydrateStore(initialState)); +} + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + hashtag: PropTypes.string, + local: PropTypes.bool, + }; + + static defaultProps = { + local: !initialState.settings.known_fediverse, + }; + + render () { + const { locale, hashtag, local } = this.props; + + let timeline; + + if (hashtag) { + timeline = <HashtagTimeline hashtag={hashtag} />; + } else { + timeline = <PublicTimeline local={local} />; + } + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <Fragment> + {timeline} + + {ReactDOM.createPortal( + <ModalContainer />, + document.getElementById('modal-container'), + )} + </Fragment> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js new file mode 100644 index 000000000..a2c00c1c2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -0,0 +1,72 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import { NavLink } from 'react-router-dom'; +import { injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; +import { me, isStaff } from 'flavours/glitch/util/initial_state'; +import { profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links'; +import Icon from 'flavours/glitch/components/icon'; + +@injectIntl +export default class ActionBar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + }; + + isStatusesPageActive = (match, location) => { + if (!match) { + return false; + } + return !location.pathname.match(/\/(followers|following)\/?$/); + } + + render () { + const { account, intl } = this.props; + + let extraInfo = ''; + + if (account.get('acct') !== account.get('username')) { + extraInfo = ( + <div className='account__disclaimer'> + <Icon icon='info-circle' fixedWidth /> <FormattedMessage + id='account.disclaimer_full' + defaultMessage="Information below may reflect the user's profile incompletely." + /> + {' '} + <a target='_blank' rel='noopener' href={account.get('url')}> + <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' /> + </a> + </div> + ); + } + + return ( + <div> + {extraInfo} + + <div className='account__action-bar'> + <div className='account__action-bar-links'> + <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> + <FormattedMessage id='account.posts' defaultMessage='Posts' /> + <strong><FormattedNumber value={account.get('statuses_count')} /></strong> + </NavLink> + + <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> + <FormattedMessage id='account.follows' defaultMessage='Follows' /> + <strong><FormattedNumber value={account.get('following_count')} /></strong> + </NavLink> + + <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> + <FormattedMessage id='account.followers' defaultMessage='Followers' /> + <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong> + </NavLink> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js new file mode 100644 index 000000000..43c4f0d32 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -0,0 +1,263 @@ +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'; +import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import Avatar from 'flavours/glitch/components/avatar'; +import Button from 'flavours/glitch/components/button'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; +import { NavLink } from 'react-router-dom'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, + account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' }, + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, + direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, + 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}' }, + pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, + mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, + endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, + unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, + add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, +}); + +const dateFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour12: false, + hour: '2-digit', + minute: '2-digit', +}; + +export default @injectIntl +class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + identity_props: ImmutablePropTypes.list, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + domain: PropTypes.string.isRequired, + }; + + openEditProfile = () => { + window.open('/settings/profile', '_blank'); + } + + render () { + const { account, intl, domain, identity_proofs } = this.props; + + if (!account) { + return null; + } + + let info = []; + let actionBtn = ''; + let lockedIcon = ''; + let menu = []; + + if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>); + } + else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>); + } + + if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>); + } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) { + info.push(<span className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain hidden' /></span>); + } + + if (me !== account.get('id')) { + if (!account.get('relationship')) { // Wait until the relationship is loaded + actionBtn = ''; + } else if (account.getIn(['relationship', 'requested'])) { + actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = <Button className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />; + } else if (account.getIn(['relationship', 'blocking'])) { + actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; + } + } else { + actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; + } + + if (account.get('moved') && !account.getIn(['relationship', 'following'])) { + actionBtn = ''; + } + + if (account.get('locked')) { + lockedIcon = <Icon icon='lock' title={intl.formatMessage(messages.account_locked)} />; + } + + if (account.get('id') !== me) { + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect }); + menu.push(null); + } + + if ('share' in navigator) { + menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); + menu.push(null); + } + + if (account.get('id') === me) { + menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); + menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); + menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); + menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); + menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); + menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); + menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); + } else { + if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'showing_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 }); + } + + menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle }); + menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList }); + menu.push(null); + } + + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); + } + + if (account.get('acct') !== account.get('username')) { + const domain = account.get('acct').split('@')[1]; + + menu.push(null); + + if (account.getIn(['relationship', 'domain_blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain }); + } + } + + if (account.get('id') !== me && isStaff) { + menu.push(null); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` }); + } + + const content = { __html: account.get('note_emojified') }; + const displayNameHtml = { __html: account.get('display_name_html') }; + const fields = account.get('fields'); + const badge = account.get('bot') ? (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>) : null; + const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); + + return ( + <div className={classNames('account__header', { inactive: !!account.get('moved') })}> + <div className='account__header__image'> + <div className='account__header__info'> + {info} + </div> + + <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' /> + </div> + + <div className='account__header__bar'> + <div className='account__header__tabs'> + <a className='avatar' href={account.get('url')} rel='noopener' target='_blank'> + <Avatar account={account} size={90} /> + </a> + + <div className='spacer' /> + + <div className='account__header__tabs__buttons'> + {actionBtn} + + <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> + </div> + </div> + + <div className='account__header__tabs__name'> + <h1> + <span dangerouslySetInnerHTML={displayNameHtml} /> {badge} + <small>@{acct} {lockedIcon}</small> + </h1> + </div> + + <div className='account__header__extra'> + <div className='account__header__bio'> + { (fields.size > 0 || identity_proofs.size > 0) && ( + <div className='account__header__fields'> + {identity_proofs.map((proof, i) => ( + <dl key={i}> + <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} /> + + <dd className='verified'> + <a href={proof.get('proof_url')} target='_blank' rel='noopener'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}> + <Icon id='check' className='verified__mark' /> + </span></a> + <a href={proof.get('profile_url')} target='_blank' rel='noopener'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a> + </dd> + </dl> + ))} + {fields.map((pair, i) => ( + <dl key={i}> + <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} /> + + <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}> + {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} /> + </dd> + </dl> + ))} + </div> + )} + + {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} + </div> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js new file mode 100644 index 000000000..b6d373a2c --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ColumnHeader from '../../../components/column_header'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, +}); + +export default @injectIntl +class ProfileColumnHeader extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + render() { + const { onClick, intl } = this.props; + + return ( + <ColumnHeader + icon='user-circle' + title={intl.formatMessage(messages.profile)} + onClick={onClick} + showBackButton + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js new file mode 100644 index 000000000..026136b2c --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js @@ -0,0 +1,150 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import classNames from 'classnames'; +import { decode } from 'blurhash'; +import { isIOS } from 'flavours/glitch/util/is_mobile'; + +export default class MediaItem extends ImmutablePureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + displayWidth: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, + }; + + state = { + visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + loaded: false, + }; + + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + + handleMouseEnter = e => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = e => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; + } + + handleClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (this.state.visible) { + this.props.onOpenMedia(this.props.attachment); + } else { + this.setState({ visible: true }); + } + } + } + + render () { + const { attachment, displayWidth } = this.props; + const { visible, loaded } = this.state; + + const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; + const height = width; + const status = attachment.get('status'); + const title = status.get('spoiler_text') || attachment.get('description'); + + let thumbnail = ''; + + if (attachment.get('type') === 'unknown') { + // Skip + } else if (attachment.get('type') === 'image') { + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + <img + src={attachment.get('preview_url')} + alt={attachment.get('description')} + title={attachment.get('description')} + style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + ); + } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { + const autoPlay = !isIOS() && autoPlayGif; + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} + title={attachment.get('description')} + role='application' + src={attachment.get('url')} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> + <span className='media-gallery__gifv__label'>GIF</span> + </div> + ); + } + + const icon = ( + <span className='account-gallery__item__icons'> + <i className='fa fa-eye-slash' /> + </span> + ); + + return ( + <div className='account-gallery__item' style={{ width, height }}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}> + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} /> + {visible ? thumbnail : icon} + </a> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js new file mode 100644 index 000000000..3e4421306 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -0,0 +1,185 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { fetchAccount } from 'flavours/glitch/actions/accounts'; +import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { getAccountGallery } from 'flavours/glitch/selectors'; +import MediaItem from './components/media_item'; +import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; +import { ScrollContainer } from 'react-router-scroll-4'; +import LoadMore from 'flavours/glitch/components/load_more'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const mapStateToProps = (state, props) => ({ + isAccount: !!state.getIn(['accounts', props.params.accountId]), + attachments: getAccountGallery(state, props.params.accountId), + isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), + hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), +}); + +class LoadMoreMedia extends ImmutablePureComponent { + + static propTypes = { + maxId: PropTypes.string, + onLoadMore: PropTypes.func.isRequired, + }; + + handleLoadMore = () => { + this.props.onLoadMore(this.props.maxId); + } + + render () { + return ( + <LoadMore + disabled={this.props.disabled} + onClick={this.handleLoadMore} + /> + ); + } + +} + +@connect(mapStateToProps) +export default class AccountGallery extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + attachments: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + isAccount: PropTypes.bool, + }; + + state = { + width: 323, + }; + + componentDidMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + handleScrollToBottom = () => { + if (this.props.hasMore) { + this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined); + } + } + + handleScroll = e => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; + + if (150 > offset && !this.props.isLoading) { + this.handleScrollToBottom(); + } + } + + handleLoadMore = maxId => { + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); + }; + + handleLoadOlder = e => { + e.preventDefault(); + this.handleScrollToBottom(); + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + setColumnRef = c => { + this.column = c; + } + + handleOpenMedia = attachment => { + if (attachment.get('type') === 'video') { + this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); + } else { + const media = attachment.getIn(['status', 'media_attachments']); + const index = media.findIndex(x => x.get('id') === attachment.get('id')); + + this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') })); + } + } + + handleRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + + render () { + const { attachments, isLoading, hasMore, isAccount } = this.props; + const { width } = this.state; + + if (!isAccount) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + + if (!attachments && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let loadOlder = null; + + if (hasMore && !(isLoading && attachments.size === 0)) { + loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; + } + + return ( + <Column ref={this.setColumnRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> + + <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> + <HeaderContainer accountId={this.props.params.accountId} /> + + <div role='feed' className='account-gallery__container' ref={this.handleRef}> + {attachments.map((attachment, index) => attachment === null ? ( + <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> + ) : ( + <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> + ))} + + {loadOlder} + </div> + + {isLoading && attachments.size === 0 && ( + <div className='scrollable__append'> + <LoadingIndicator /> + </div> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js new file mode 100644 index 000000000..0faa8a424 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js @@ -0,0 +1,130 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import InnerHeader from 'flavours/glitch/features/account/components/header'; +import ActionBar from 'flavours/glitch/features/account/components/action_bar'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; +import { NavLink } from 'react-router-dom'; +import MovedNote from './moved_note'; + +export default class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + identity_proofs: ImmutablePropTypes.list, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onBlockDomain: PropTypes.func.isRequired, + onUnblockDomain: PropTypes.func.isRequired, + onEndorseToggle: PropTypes.func.isRequired, + onAddToList: PropTypes.func.isRequired, + hideTabs: PropTypes.bool, + domain: PropTypes.string.isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMention = () => { + this.props.onMention(this.props.account, this.context.router.history); + } + + handleDirect = () => { + this.props.onDirect(this.props.account, this.context.router.history); + } + + handleReport = () => { + this.props.onReport(this.props.account); + } + + handleReblogToggle = () => { + this.props.onReblogToggle(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + handleBlockDomain = () => { + const domain = this.props.account.get('acct').split('@')[1]; + + if (!domain) return; + + this.props.onBlockDomain(domain); + } + + handleUnblockDomain = () => { + const domain = this.props.account.get('acct').split('@')[1]; + + if (!domain) return; + + this.props.onUnblockDomain(domain); + } + + handleEndorseToggle = () => { + this.props.onEndorseToggle(this.props.account); + } + + handleAddToList = () => { + this.props.onAddToList(this.props.account); + } + + render () { + const { account, hideTabs, identity_proofs } = this.props; + + if (account === null) { + return null; + } + + return ( + <div className='account-timeline__header'> + {account.get('moved') && <MovedNote from={account} to={account.get('moved')} />} + + <InnerHeader + account={account} + identity_proofs={identity_proofs} + onFollow={this.handleFollow} + onBlock={this.handleBlock} + onMention={this.handleMention} + onDirect={this.handleDirect} + onReblogToggle={this.handleReblogToggle} + onReport={this.handleReport} + onMute={this.handleMute} + onBlockDomain={this.handleBlockDomain} + onUnblockDomain={this.handleUnblockDomain} + onEndorseToggle={this.handleEndorseToggle} + onAddToList={this.handleAddToList} + domain={this.props.domain} + /> + + <ActionBar + account={account} + /> + + {!hideTabs && ( + <div className='account__section-headline'> + <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> + <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink> + <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> + </div> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js new file mode 100644 index 000000000..1fab083db --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import AvatarOverlay from '../../../components/avatar_overlay'; +import DisplayName from '../../../components/display_name'; + +export default class MovedNote extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + from: ImmutablePropTypes.map.isRequired, + to: ImmutablePropTypes.map.isRequired, + }; + + handleAccountClick = e => { + if (e.button === 0) { + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.to.get('id')}`, state); + } + + e.stopPropagation(); + } + + render () { + const { from, to } = this.props; + const displayNameHtml = { __html: from.get('display_name_html') }; + + return ( + <div className='account__moved-note'> + <div className='account__moved-note__message'> + <div className='account__moved-note__icon-wrapper'><i className='fa fa-fw fa-suitcase account__moved-note__icon' /></div> + <FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} /> + </div> + + <a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div> + <DisplayName account={to} /> + </a> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js new file mode 100644 index 000000000..787a36658 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Header from '../components/header'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + unmuteAccount, + pinAccount, + unpinAccount, +} from 'flavours/glitch/actions/accounts'; +import { + mentionCompose, + directCompose +} from 'flavours/glitch/actions/compose'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { unfollowModal } from 'flavours/glitch/util/initial_state'; +import { List as ImmutableList } from 'immutable'; + +const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + domain: state.getIn(['meta', 'domain']), + identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + 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'))), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account)); + }, + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onReblogToggle (account) { + if (account.getIn(['relationship', 'showing_reblogs'])) { + dispatch(followAccount(account.get('id'), false)); + } else { + dispatch(followAccount(account.get('id'), true)); + } + }, + + onEndorseToggle (account) { + if (account.getIn(['relationship', 'endorsed'])) { + dispatch(unpinAccount(account.get('id'))); + } else { + dispatch(pinAccount(account.get('id'))); + } + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + + onBlockDomain (domain) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, + + onAddToList(account){ + dispatch(openModal('LIST_ADDER', { + accountId: account.get('id'), + })); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js new file mode 100644 index 000000000..93d8fc9ec --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -0,0 +1,116 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { fetchAccount } from 'flavours/glitch/actions/accounts'; +import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines'; +import StatusList from '../../components/status_list'; +import LoadingIndicator from '../../components/loading_indicator'; +import Column from '../ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; +import HeaderContainer from './containers/header_container'; +import { List as ImmutableList } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; +import { fetchAccountIdentityProofs } from '../../actions/identity_proofs'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; + +const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { + const path = withReplies ? `${accountId}:with_replies` : accountId; + + return { + isAccount: !!state.getIn(['accounts', accountId]), + statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), + featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), + isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), + hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), + }; +}; + +@connect(mapStateToProps) +export default class AccountTimeline extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list, + featuredStatusIds: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + withReplies: PropTypes.bool, + isAccount: PropTypes.bool, + }; + + componentWillMount () { + const { params: { accountId }, withReplies } = this.props; + + this.props.dispatch(fetchAccount(accountId)); + this.props.dispatch(fetchAccountIdentityProofs(accountId)); + if (!withReplies) { + this.props.dispatch(expandAccountFeaturedTimeline(accountId)); + } + this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); + } + + componentWillReceiveProps (nextProps) { + if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId)); + if (!nextProps.withReplies) { + this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); + } + this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + handleLoadMore = maxId => { + this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); + } + + setRef = c => { + this.column = c; + } + + render () { + const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount } = this.props; + + if (!isAccount) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + + if (!statusIds && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column ref={this.setRef} name='account'> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> + + <StatusList + prepend={<HeaderContainer accountId={this.props.params.accountId} />} + alwaysPrepend + scrollKey='account_timeline' + statusIds={statusIds} + featuredStatusIds={featuredStatusIds} + isLoading={isLoading} + hasMore={hasMore} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/blocks/index.js b/app/javascript/flavours/glitch/features/blocks/index.js new file mode 100644 index 000000000..386a0ce63 --- /dev/null +++ b/app/javascript/flavours/glitch/features/blocks/index.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import { fetchBlocks, expandBlocks } from 'flavours/glitch/actions/blocks'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'blocks', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Blocks extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchBlocks()); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandBlocks()); + } + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='blocks' icon='ban' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='blocks' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js new file mode 100644 index 000000000..9468ad81d --- /dev/null +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import StatusList from 'flavours/glitch/components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { debounce } from 'lodash'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), + isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Bookmarks extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchBookmarkedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('BOOKMARKS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandBookmarkedStatuses()); + }, 300, { leading: true }) + + render () { + const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='bookmarks'> + <ColumnHeader + icon='bookmark' + title={intl.formatMessage(messages.heading)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + /> + + <StatusList + trackScroll={!pinned} + statusIds={statusIds} + scrollKey={`bookmarked_statuses-${columnId}`} + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js new file mode 100644 index 000000000..96db003ce --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingText from 'flavours/glitch/components/setting_text'; +import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..b892f08ad --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeColumnParams } from 'flavours/glitch/actions/columns'; +import { changeSetting } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = (state, { columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + + return { + settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'community']), + }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => { + return { + onChange (key, checked) { + if (columnId) { + dispatch(changeColumnParams(columnId, key, checked)); + } else { + dispatch(changeSetting(['community', ...key], checked)); + } + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js new file mode 100644 index 000000000..2c0fbff36 --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectCommunityStream } from 'flavours/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local timeline' }, +}); + +const mapStateToProps = (state, { onlyMedia, columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + + return { + hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0, + onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']), + }; +}; + +@connect(mapStateToProps) +@injectIntl +export default class CommunityTimeline extends React.PureComponent { + + static defaultProps = { + onlyMedia: false, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + onlyMedia: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch, onlyMedia } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('COMMUNITY', { other: { onlyMedia } })); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch, onlyMedia } = this.props; + + dispatch(expandCommunityTimeline({ onlyMedia })); + this.disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } + + componentDidUpdate (prevProps) { + if (prevProps.onlyMedia !== this.props.onlyMedia) { + const { dispatch, onlyMedia } = this.props; + + this.disconnect(); + dispatch(expandCommunityTimeline({ onlyMedia })); + this.disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { dispatch, onlyMedia } = this.props; + + dispatch(expandCommunityTimeline({ maxId, onlyMedia })); + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen) + } + + render () { + const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='local' label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='users' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer columnId={columnId} /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`community_timeline-${columnId}`} + shouldUpdateScroll={this.shouldUpdateScroll} + timelineId={`community${onlyMedia ? ':media' : ''}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js new file mode 100644 index 000000000..fb9bb5035 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js @@ -0,0 +1,24 @@ +import React from 'react'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class AutosuggestAccount extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + + return ( + <div className='account small' title={account.get('acct')}> + <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div> + <DisplayName account={account} inline /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js new file mode 100644 index 000000000..e8f000b1e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -0,0 +1,378 @@ +import React from 'react'; +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 AutosuggestInput from '../../../components/autosuggest_input'; +import { defineMessages, injectIntl } from 'react-intl'; +import EmojiPicker from 'flavours/glitch/features/emoji_picker'; +import PollFormContainer from '../containers/poll_form_container'; +import UploadFormContainer from '../containers/upload_form_container'; +import WarningContainer from '../containers/warning_container'; +import { isMobile } from 'flavours/glitch/util/is_mobile'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { countableText } from 'flavours/glitch/util/counter'; +import OptionsContainer from '../containers/options_container'; +import Publisher from './publisher'; +import TextareaIcons from './textarea_icons'; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + missingDescriptionMessage: { id: 'confirmations.missing_media_description.message', + defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, + missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', + defaultMessage: 'Send anyway' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, +}); + +export default @injectIntl +class ComposeForm extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + text: PropTypes.string, + suggestions: ImmutablePropTypes.list, + spoiler: PropTypes.bool, + privacy: PropTypes.string, + spoilerText: PropTypes.string, + focusDate: PropTypes.instanceOf(Date), + caretPosition: PropTypes.number, + preselectDate: PropTypes.instanceOf(Date), + isSubmitting: PropTypes.bool, + isChangingUpload: PropTypes.bool, + isUploading: PropTypes.bool, + onChange: PropTypes.func, + onSubmit: PropTypes.func, + onClearSuggestions: PropTypes.func, + onFetchSuggestions: PropTypes.func, + onSuggestionSelected: PropTypes.func, + onChangeSpoilerText: PropTypes.func, + onPaste: PropTypes.func, + onPickEmoji: PropTypes.func, + showSearch: PropTypes.bool, + anyMedia: PropTypes.bool, + + advancedOptions: ImmutablePropTypes.map, + layout: PropTypes.string, + media: ImmutablePropTypes.list, + sideArm: PropTypes.string, + sensitive: PropTypes.bool, + spoilersAlwaysOn: PropTypes.bool, + mediaDescriptionConfirmation: PropTypes.bool, + preselectOnReply: PropTypes.bool, + onChangeSpoilerness: PropTypes.func, + onChangeVisibility: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + onPaste: PropTypes.func, + onMediaDescriptionConfirm: PropTypes.func, + }; + + static defaultProps = { + showSearch: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + handleKeyDown = ({ ctrlKey, keyCode, metaKey, altKey }) => { + // We submit the status on control/meta + enter. + if (keyCode === 13 && (ctrlKey || metaKey)) { + this.handleSubmit(); + } + + // Submit the status with secondary visibility on alt + enter. + if (keyCode === 13 && altKey) { + this.handleSecondarySubmit(); + } + } + + handleSubmit = () => { + const { textarea: { value }, uploadForm } = this; + const { + onChange, + onSubmit, + isSubmitting, + isChangingUpload, + isUploading, + media, + anyMedia, + text, + mediaDescriptionConfirmation, + onMediaDescriptionConfirm, + } = this.props; + + // If something changes inside the textarea, then we update the + // state before submitting. + if (onChange && text !== value) { + onChange(value); + } + + // Submit disabled: + if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) { + return; + } + + // Submit unless there are media with missing descriptions + if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) { + const firstWithoutDescription = media.findIndex(item => !item.get('description')); + if (uploadForm) { + const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input'); + if (inputs.length == media.size && firstWithoutDescription !== -1) { + inputs[firstWithoutDescription].focus(); + } + } + onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null); + } else if (onSubmit) { + onSubmit(this.context.router ? this.context.router.history : null); + } + } + + // Changes the text value of the spoiler. + handleChangeSpoiler = ({ target: { value } }) => { + const { onChangeSpoilerText } = this.props; + if (onChangeSpoilerText) { + onChangeSpoilerText(value); + } + } + + // Inserts an emoji at the caret. + handleEmoji = (data) => { + const { textarea: { selectionStart } } = this; + const { onPickEmoji } = this.props; + if (onPickEmoji) { + onPickEmoji(selectionStart, data); + } + } + + // Handles the secondary submit button. + handleSecondarySubmit = () => { + const { + onChangeVisibility, + sideArm, + } = this.props; + if (sideArm !== 'none' && onChangeVisibility) { + onChangeVisibility(sideArm); + } + this.handleSubmit(); + } + + // Selects a suggestion from the autofill. + onSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['text']); + } + + onSpoilerSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']); + } + + // When the escape key is released, we focus the UI. + handleKeyUp = ({ key }) => { + if (key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); + } + } + + // Sets a reference to the textarea. + setAutosuggestTextarea = (textareaComponent) => { + if (textareaComponent) { + this.textarea = textareaComponent.textarea; + } + } + + // Sets a reference to the CW field. + handleRefSpoilerText = (spoilerComponent) => { + if (spoilerComponent) { + this.spoilerText = spoilerComponent.input; + } + } + + // Tells our state the composer has been mounted. + componentDidMount () { + const { onMount } = this.props; + if (onMount) { + onMount(); + } + } + + // Tells our state the composer has been unmounted. + componentWillUnmount () { + const { onUnmount } = this.props; + if (onUnmount) { + onUnmount(); + } + } + + // This statement does several things: + // - If we're beginning a reply, and, + // - Replying to zero or one users, places the cursor at the end + // of the textbox. + // - Replying to more than one user, selects any usernames past + // the first; this provides a convenient shortcut to drop + // everyone else from the conversation. + componentDidUpdate (prevProps) { + const { + textarea, + spoilerText, + } = this; + const { + focusDate, + caretPosition, + isSubmitting, + preselectDate, + text, + preselectOnReply, + } = this.props; + let selectionEnd, selectionStart; + + // Caret/selection handling. + if (focusDate !== prevProps.focusDate) { + switch (true) { + case preselectDate !== prevProps.preselectDate && preselectOnReply: + selectionStart = text.search(/\s/) + 1; + selectionEnd = text.length; + break; + case !isNaN(caretPosition) && caretPosition !== null: + selectionStart = selectionEnd = caretPosition; + break; + default: + selectionStart = selectionEnd = text.length; + } + if (textarea) { + textarea.setSelectionRange(selectionStart, selectionEnd); + textarea.focus(); + textarea.scrollIntoView(); + } + + // Refocuses the textarea after submitting. + } else if (textarea && prevProps.isSubmitting && !isSubmitting) { + textarea.focus(); + } else if (this.props.spoiler !== prevProps.spoiler) { + if (this.props.spoiler) { + if (spoilerText) { + spoilerText.focus(); + } + } else { + if (textarea) { + textarea.focus(); + } + } + } + } + + + render () { + const { + handleEmoji, + handleSecondarySubmit, + handleSelect, + handleSubmit, + handleRefTextarea, + } = this; + const { + advancedOptions, + anyMedia, + intl, + isSubmitting, + isChangingUpload, + isUploading, + layout, + media, + onChangeSpoilerness, + onChangeVisibility, + onClearSuggestions, + onFetchSuggestions, + onPaste, + privacy, + sensitive, + showSearch, + sideArm, + spoiler, + spoilerText, + suggestions, + text, + spoilersAlwaysOn, + } = this.props; + + let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); + + return ( + <div className='composer'> + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}> + <AutosuggestInput + placeholder={intl.formatMessage(messages.spoiler_placeholder)} + value={spoilerText} + onChange={this.handleChangeSpoiler} + onKeyDown={this.handleKeyDown} + onKeyUp={this.handleKeyUp} + disabled={!spoiler} + ref={this.handleRefSpoilerText} + suggestions={this.props.suggestions} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionSelected={this.onSpoilerSuggestionSelected} + searchTokens={[':']} + id='glitch.composer.spoiler.input' + className='spoiler-input__input' + /> + </div> + + <div className='composer--textarea'> + <TextareaIcons advancedOptions={advancedOptions} /> + + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={isSubmitting} + value={this.props.text} + onChange={this.handleChange} + suggestions={this.props.suggestions} + onKeyDown={this.handleKeyDown} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} + /> + + <EmojiPicker onPickEmoji={handleEmoji} /> + </div> + + <div className='compose-form__modifiers'> + <UploadFormContainer /> + <PollFormContainer /> + </div> + + <OptionsContainer + advancedOptions={advancedOptions} + disabled={isSubmitting} + onChangeVisibility={onChangeVisibility} + onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} + onUpload={onPaste} + privacy={privacy} + sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} + spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} + /> + + <Publisher + countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} + disabled={disabledButton} + onSecondarySubmit={handleSecondarySubmit} + onSubmit={handleSubmit} + privacy={privacy} + sideArm={sideArm} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js new file mode 100644 index 000000000..8d982208f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -0,0 +1,229 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Overlay from 'react-overlays/lib/Overlay'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import DropdownMenu from './dropdown_menu'; + +// Utils. +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // Closes the dropdown. + handleClose () { + this.setState({ open: false }); + }, + + // The enter key toggles the dropdown's open state, and the escape + // key closes it. + handleKeyDown ({ key }) { + const { + handleClose, + handleToggle, + } = this.handlers; + switch (key) { + case 'Enter': + handleToggle(key); + break; + case 'Escape': + handleClose(); + break; + } + }, + + // Creates an action modal object. + handleMakeModal () { + const component = this; + const { + items, + onChange, + onModalOpen, + onModalClose, + value, + } = this.props; + + // Required props. + if (!(onChange && onModalOpen && onModalClose && items)) { + return null; + } + + // The object. + return { + actions: items.map( + ({ + name, + ...rest + }) => ({ + ...rest, + active: value && name === value, + name, + onClick (e) { + e.preventDefault(); // Prevents focus from changing + onModalClose(); + onChange(name); + }, + onPassiveClick (e) { + e.preventDefault(); // Prevents focus from changing + onChange(name); + component.setState({ needsModalUpdate: true }); + }, + }) + ), + }; + }, + + // Toggles opening and closing the dropdown. + handleToggle ({ target }) { + const { handleMakeModal } = this.handlers; + const { onModalOpen } = this.props; + const { open } = this.state; + + // If this is a touch device, we open a modal instead of the + // dropdown. + if (isUserTouching()) { + + // This gets the modal to open. + const modal = handleMakeModal(); + + // If we can, we then open the modal. + if (modal && onModalOpen) { + onModalOpen(modal); + return; + } + } + + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + // Otherwise, we just set our state to open. + this.setState({ open: !open }); + }, + + // If our modal is open and our props update, we need to also update + // the modal. + handleUpdate () { + const { handleMakeModal } = this.handlers; + const { onModalOpen } = this.props; + const { needsModalUpdate } = this.state; + + // Gets our modal object. + const modal = handleMakeModal(); + + // Reopens the modal with the new object. + if (needsModalUpdate && modal && onModalOpen) { + onModalOpen(modal); + } + }, +}; + +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { + needsModalUpdate: false, + open: false, + placement: 'bottom', + }; + } + + // Updates our modal as necessary. + componentDidUpdate (prevProps) { + const { handleUpdate } = this.handlers; + const { items } = this.props; + const { needsModalUpdate } = this.state; + if (needsModalUpdate && items.find( + (item, i) => item.on !== prevProps.items[i].on + )) { + handleUpdate(); + this.setState({ needsModalUpdate: false }); + } + } + + // Rendering. + render () { + const { + handleClose, + handleKeyDown, + handleToggle, + } = this.handlers; + const { + active, + disabled, + title, + icon, + items, + onChange, + value, + } = this.props; + const { open, placement } = this.state; + const computedClass = classNames('composer--options--dropdown', { + active, + open, + top: placement === 'top', + }); + + // The result. + return ( + <div + className={computedClass} + onKeyDown={handleKeyDown} + > + <IconButton + active={open || active} + className='value' + disabled={disabled} + icon={icon} + onClick={handleToggle} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + <Overlay + containerPadding={20} + placement={placement} + show={open} + target={this} + > + <DropdownMenu + items={items} + onChange={onChange} + onClose={handleClose} + value={value} + /> + </Overlay> + </div> + ); + } + +} + +// Props. +ComposerOptionsDropdown.propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onChange: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js new file mode 100644 index 000000000..19d35a8f4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -0,0 +1,219 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import spring from 'react-motion/lib/spring'; +import Toggle from 'react-toggle'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { withPassive } from 'flavours/glitch/util/dom_helpers'; +import Motion from 'flavours/glitch/util/optional_motion'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +class ComposerOptionsDropdownContentItem extends ImmutablePureComponent { + + static propTypes = { + active: PropTypes.bool, + name: PropTypes.string, + onChange: PropTypes.func, + onClose: PropTypes.func, + options: PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + on: PropTypes.bool, + text: PropTypes.node, + }), + }; + + handleActivate = (e) => { + const { + name, + onChange, + onClose, + options: { on }, + } = this.props; + + // If the escape key was pressed, we close the dropdown. + if (e.key === 'Escape' && onClose) { + onClose(); + + // Otherwise, we both close the dropdown and change the value. + } else if (onChange && (!e.key || e.key === 'Enter')) { + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined') && onClose) { + onClose(); + } + onChange(name); + } + } + + // Rendering. + render () { + const { + active, + options: { + icon, + meta, + on, + text, + }, + } = this.props; + const computedClass = classNames('composer--options--dropdown--content--item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + let prefix = null; + + if (on !== null && typeof on !== 'undefined') { + prefix = <Toggle checked={on} onChange={this.handleActivate} />; + } else if (icon) { + prefix = <Icon className='icon' fullwidth icon={icon} /> + } + + // The result. + return ( + <div + className={computedClass} + onClick={this.handleActivate} + onKeyDown={this.handleActivate} + role='button' + tabIndex='0' + > + {prefix} + + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + </div> + ); + } + +}; + +// The spring to use with our motion. +const springMotion = spring(1, { + damping: 35, + stiffness: 400, +}); + +// The component. +export default class ComposerOptionsDropdownContent extends React.PureComponent { + + static propTypes = { + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })), + onChange: PropTypes.func, + onClose: PropTypes.func, + style: PropTypes.object, + value: PropTypes.string, + }; + + static defaultProps = { + style: {}, + }; + + state = { + mounted: false, + }; + + // When the document is clicked elsewhere, we close the dropdown. + handleDocumentClick = ({ target }) => { + const { node } = this; + const { onClose } = this.props; + if (onClose && node && !node.contains(target)) { + onClose(); + } + } + + // Stores our node in `this.node`. + handleRef = (node) => { + this.node = node; + } + + // On mounting, we add our listeners. + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, withPassive); + this.setState({ mounted: true }); + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, withPassive); + } + + // Rendering. + render () { + const { mounted } = this.state; + const { + items, + onChange, + onClose, + style, + value, + } = this.props; + + // The result. + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: springMotion, + scaleX: springMotion, + scaleY: springMotion, + }} + > + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays + <div + className='composer--options--dropdown--content' + ref={this.handleRef} + style={{ + ...style, + opacity: opacity, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, + }} + > + {items ? items.map( + ({ + name, + ...rest + }) => ( + <ComposerOptionsDropdownContentItem + active={name === value} + key={name} + name={name} + onChange={onChange} + onClose={onClose} + options={rest} + /> + ) + ) : null} + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js new file mode 100644 index 000000000..2e29084f2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/header.js @@ -0,0 +1,124 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; +import { Link } from 'react-router-dom'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { conditionalRender } from 'flavours/glitch/util/react_helpers'; +import { signOutLink } from 'flavours/glitch/util/backend_links'; + +// Messages. +const messages = defineMessages({ + community: { + defaultMessage: 'Local timeline', + id: 'navigation_bar.community_timeline', + }, + home_timeline: { + defaultMessage: 'Home', + id: 'tabs_bar.home', + }, + logout: { + defaultMessage: 'Logout', + id: 'navigation_bar.logout', + }, + notifications: { + defaultMessage: 'Notifications', + id: 'tabs_bar.notifications', + }, + public: { + defaultMessage: 'Federated timeline', + id: 'navigation_bar.public_timeline', + }, + settings: { + defaultMessage: 'App settings', + id: 'navigation_bar.app_settings', + }, + start: { + defaultMessage: 'Getting started', + id: 'getting_started.heading', + }, +}); + +export default @injectIntl +class Header extends ImmutablePureComponent { + static propTypes = { + columns: ImmutablePropTypes.list, + unreadNotifications: PropTypes.number, + showNotificationsBadge: PropTypes.bool, + intl: PropTypes.object, + onSettingsClick: PropTypes.func, + }; + + render () { + const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props; + + // Only renders the component if the column isn't being shown. + const renderForColumn = conditionalRender.bind(null, + columnId => !columns || !columns.some( + column => column.get('id') === columnId + ) + ); + + // The result. + return ( + <nav className='drawer--header'> + <Link + aria-label={intl.formatMessage(messages.start)} + title={intl.formatMessage(messages.start)} + to='/getting-started' + ><Icon icon='asterisk' /></Link> + {renderForColumn('HOME', ( + <Link + aria-label={intl.formatMessage(messages.home_timeline)} + title={intl.formatMessage(messages.home_timeline)} + to='/timelines/home' + ><Icon icon='home' /></Link> + ))} + {renderForColumn('NOTIFICATIONS', ( + <Link + aria-label={intl.formatMessage(messages.notifications)} + title={intl.formatMessage(messages.notifications)} + to='/notifications' + > + <span className='icon-badge-wrapper'> + <Icon icon='bell' /> + { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />} + </span> + </Link> + ))} + {renderForColumn('COMMUNITY', ( + <Link + aria-label={intl.formatMessage(messages.community)} + title={intl.formatMessage(messages.community)} + to='/timelines/public/local' + ><Icon icon='users' /></Link> + ))} + {renderForColumn('PUBLIC', ( + <Link + aria-label={intl.formatMessage(messages.public)} + title={intl.formatMessage(messages.public)} + to='/timelines/public' + ><Icon icon='globe' /></Link> + ))} + <a + aria-label={intl.formatMessage(messages.settings)} + onClick={onSettingsClick} + href='#' + title={intl.formatMessage(messages.settings)} + ><Icon icon='cogs' /></a> + <a + aria-label={intl.formatMessage(messages.logout)} + data-method='delete' + href={ signOutLink } + title={intl.formatMessage(messages.logout)} + ><Icon icon='sign-out' /></a> + </nav> + ); + }; +} diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js new file mode 100644 index 000000000..59172bb23 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js @@ -0,0 +1,36 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import Permalink from 'flavours/glitch/components/permalink'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { profileLink } from 'flavours/glitch/util/backend_links'; + +export default class NavigationBar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + return ( + <div className='drawer--account'> + <Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> + <Avatar account={this.props.account} size={40} /> + </Permalink> + + <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong>@{this.props.account.get('acct')}</strong> + </Permalink> + + { profileLink !== undefined && ( + <a + className='edit' + href={ profileLink } + ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + )} + </div> + ); + }; +} diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js new file mode 100644 index 000000000..ee9730961 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -0,0 +1,311 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import TextIconButton from 'flavours/glitch/components/text_icon_button'; +import Dropdown from './dropdown'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; + +// Messages. +const messages = defineMessages({ + advanced_options_icon_title: { + defaultMessage: 'Advanced options', + id: 'advanced_options.icon_title', + }, + attach: { + defaultMessage: 'Attach...', + id: 'compose.attach', + }, + change_privacy: { + defaultMessage: 'Adjust status privacy', + id: 'privacy.change', + }, + direct_long: { + defaultMessage: 'Post to mentioned users only', + id: 'privacy.direct.long', + }, + direct_short: { + defaultMessage: 'Direct', + id: 'privacy.direct.short', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + local_only_long: { + defaultMessage: 'Do not post to other instances', + id: 'advanced_options.local-only.long', + }, + local_only_short: { + defaultMessage: 'Local-only', + id: 'advanced_options.local-only.short', + }, + private_long: { + defaultMessage: 'Post to followers only', + id: 'privacy.private.long', + }, + private_short: { + defaultMessage: 'Followers-only', + id: 'privacy.private.short', + }, + public_long: { + defaultMessage: 'Post to public timelines', + id: 'privacy.public.long', + }, + public_short: { + defaultMessage: 'Public', + id: 'privacy.public.short', + }, + spoiler: { + defaultMessage: 'Hide text behind warning', + id: 'compose_form.spoiler', + }, + threaded_mode_long: { + defaultMessage: 'Automatically opens a reply on posting', + id: 'advanced_options.threaded_mode.long', + }, + threaded_mode_short: { + defaultMessage: 'Threaded mode', + id: 'advanced_options.threaded_mode.short', + }, + unlisted_long: { + defaultMessage: 'Do not show in public timelines', + id: 'privacy.unlisted.long', + }, + unlisted_short: { + defaultMessage: 'Unlisted', + id: 'privacy.unlisted.short', + }, + upload: { + defaultMessage: 'Upload a file', + id: 'compose.attach.upload', + }, + add_poll: { + defaultMessage: 'Add a poll', + id: 'poll_button.add_poll', + }, + remove_poll: { + defaultMessage: 'Remove poll', + id: 'poll_button.remove_poll', + }, +}); + +export default @injectIntl +class ComposerOptions extends ImmutablePureComponent { + + static propTypes = { + acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + disabled: PropTypes.bool, + allowMedia: PropTypes.bool, + hasMedia: PropTypes.bool, + allowPoll: PropTypes.bool, + hasPoll: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChangeAdvancedOption: PropTypes.func, + onChangeVisibility: PropTypes.func, + onTogglePoll: PropTypes.func, + onDoodleOpen: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + onToggleSpoiler: PropTypes.func, + onUpload: PropTypes.func, + privacy: PropTypes.string, + resetFileKey: PropTypes.number, + spoiler: PropTypes.bool, + }; + + // Handles file selection. + handleChangeFiles = ({ target: { files } }) => { + const { onUpload } = this.props; + if (files.length && onUpload) { + onUpload(files); + } + } + + // Handles attachment clicks. + handleClickAttach = (name) => { + const { fileElement } = this; + const { onDoodleOpen } = this.props; + + // We switch over the name of the option. + switch (name) { + case 'upload': + if (fileElement) { + fileElement.click(); + } + return; + case 'doodle': + if (onDoodleOpen) { + onDoodleOpen(); + } + return; + } + } + + // Handles a ref to the file input. + handleRefFileElement = (fileElement) => { + this.fileElement = fileElement; + } + + // Rendering. + render () { + const { + acceptContentTypes, + advancedOptions, + disabled, + allowMedia, + hasMedia, + allowPoll, + hasPoll, + intl, + onChangeAdvancedOption, + onChangeVisibility, + onTogglePoll, + onModalClose, + onModalOpen, + onToggleSpoiler, + privacy, + resetFileKey, + spoiler, + } = this.props; + + // We predefine our privacy items so that we can easily pick the + // dropdown icon later. + const privacyItems = { + direct: { + icon: 'envelope', + meta: <FormattedMessage {...messages.direct_long} />, + name: 'direct', + text: <FormattedMessage {...messages.direct_short} />, + }, + private: { + icon: 'lock', + meta: <FormattedMessage {...messages.private_long} />, + name: 'private', + text: <FormattedMessage {...messages.private_short} />, + }, + public: { + icon: 'globe', + meta: <FormattedMessage {...messages.public_long} />, + name: 'public', + text: <FormattedMessage {...messages.public_short} />, + }, + unlisted: { + icon: 'unlock', + meta: <FormattedMessage {...messages.unlisted_long} />, + name: 'unlisted', + text: <FormattedMessage {...messages.unlisted_short} />, + }, + }; + + // The result. + return ( + <div className='composer--options'> + <input + accept={acceptContentTypes} + disabled={disabled || !allowMedia} + key={resetFileKey} + onChange={this.handleChangeFiles} + ref={this.handleRefFileElement} + type='file' + multiple + style={{ display: 'none' }} + /> + <Dropdown + disabled={disabled || !allowMedia} + icon='paperclip' + items={[ + { + icon: 'cloud-upload', + name: 'upload', + text: <FormattedMessage {...messages.upload} />, + }, + { + icon: 'paint-brush', + name: 'doodle', + text: <FormattedMessage {...messages.doodle} />, + }, + ]} + onChange={this.handleClickAttach} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.attach)} + /> + {!!pollLimits && ( + <IconButton + active={hasPoll} + disabled={disabled || !allowPoll} + icon='tasks' + inverted + onClick={onTogglePoll} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} + /> + )} + <hr /> + <Dropdown + disabled={disabled} + icon={(privacyItems[privacy] || {}).icon} + items={[ + privacyItems.public, + privacyItems.unlisted, + privacyItems.private, + privacyItems.direct, + ]} + onChange={onChangeVisibility} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.change_privacy)} + value={privacy} + /> + {onToggleSpoiler && ( + <TextIconButton + active={spoiler} + ariaControls='glitch.composer.spoiler.input' + label='CW' + onClick={onToggleSpoiler} + title={intl.formatMessage(messages.spoiler)} + /> + )} + <Dropdown + active={advancedOptions && advancedOptions.some(value => !!value)} + disabled={disabled} + icon='ellipsis-h' + items={advancedOptions ? [ + { + meta: <FormattedMessage {...messages.local_only_long} />, + name: 'do_not_federate', + on: advancedOptions.get('do_not_federate'), + text: <FormattedMessage {...messages.local_only_short} />, + }, + { + meta: <FormattedMessage {...messages.threaded_mode_long} />, + name: 'threaded_mode', + on: advancedOptions.get('threaded_mode'), + text: <FormattedMessage {...messages.threaded_mode_short} />, + }, + ] : null} + onChange={onChangeAdvancedOption} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.advanced_options_icon_title)} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js new file mode 100644 index 000000000..21b5d3d73 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js @@ -0,0 +1,160 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Icon from 'flavours/glitch/components/icon'; +import AutosuggestInput from 'flavours/glitch/components/autosuggest_input'; +import classNames from 'classnames'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, + add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, + remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, + poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, + single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' }, + multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' }, + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}); + +@injectIntl +class Option extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + isPollMultiple: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleOptionTitleChange = e => { + this.props.onChange(this.props.index, e.target.value); + }; + + handleOptionRemove = () => { + this.props.onRemove(this.props.index); + }; + + onSuggestionsClearRequested = () => { + this.props.onClearSuggestions(); + } + + onSuggestionsFetchRequested = (token) => { + this.props.onFetchSuggestions(token); + } + + onSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]); + } + + render () { + const { isPollMultiple, title, index, intl } = this.props; + + return ( + <li> + <label className='poll__text editable'> + <span className={classNames('poll__input', { checkbox: isPollMultiple })} /> + + <AutosuggestInput + placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} + maxLength={pollLimits.max_option_chars} + value={title} + onChange={this.handleOptionTitleChange} + suggestions={this.props.suggestions} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + searchTokens={[':']} + /> + </label> + + <div className='poll__cancel'> + <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> + </div> + </li> + ); + } + +} + +export default +@injectIntl +class PollForm extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.list, + expiresIn: PropTypes.number, + isMultiple: PropTypes.bool, + onChangeOption: PropTypes.func.isRequired, + onAddOption: PropTypes.func.isRequired, + onRemoveOption: PropTypes.func.isRequired, + onChangeSettings: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleAddOption = () => { + this.props.onAddOption(''); + }; + + handleSelectDuration = e => { + this.props.onChangeSettings(e.target.value, this.props.isMultiple); + }; + + handleSelectMultiple = e => { + this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true'); + }; + + render () { + const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props; + + if (!options) { + return null; + } + + return ( + <div className='compose-form__poll-wrapper'> + <ul> + {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} {...other} />)} + {options.size < pollLimits.max_options && ( + <label className='poll__text editable'> + <span className={classNames('poll__input')} style={{ opacity: 0 }} /> + <button className='button button-secondary' onClick={this.handleAddOption}><Icon icon='plus' /> <FormattedMessage {...messages.add_option} /></button> + </label> + )} + </ul> + + <div className='poll__footer'> + <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}> + <option value='false'>{intl.formatMessage(messages.single_choice)}</option> + <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option> + </select> + + <select value={expiresIn} onChange={this.handleSelectDuration}> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js new file mode 100644 index 000000000..e283b32b9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -0,0 +1,115 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { length } from 'stringz'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import Button from 'flavours/glitch/components/button'; +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { maxChars } from 'flavours/glitch/util/initial_state'; + +// Messages. +const messages = defineMessages({ + publish: { + defaultMessage: 'Toot', + id: 'compose_form.publish', + }, + publishLoud: { + defaultMessage: '{publish}!', + id: 'compose_form.publish_loud', + }, +}); + +export default @injectIntl +class Publisher extends ImmutablePureComponent { + + static propTypes = { + countText: PropTypes.string, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onSecondarySubmit: PropTypes.func, + onSubmit: PropTypes.func, + privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), + sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), + }; + + render () { + const { countText, disabled, intl, onSecondarySubmit, onSubmit, privacy, sideArm } = this.props; + + const diff = maxChars - length(countText || ''); + const computedClass = classNames('composer--publisher', { + disabled: disabled || diff < 0, + over: diff < 0, + }); + + return ( + <div className={computedClass}> + <span className='count'>{diff}</span> + {sideArm && sideArm !== 'none' ? ( + <Button + className='side_arm' + disabled={disabled || diff < 0} + onClick={onSecondarySubmit} + style={{ padding: null }} + text={ + <span> + <Icon + icon={{ + public: 'globe', + unlisted: 'unlock', + private: 'lock', + direct: 'envelope', + }[sideArm]} + /> + </span> + } + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} + /> + ) : null} + <Button + className='primary' + text={function () { + switch (true) { + case !!sideArm && sideArm !== 'none': + case privacy === 'direct': + case privacy === 'private': + return ( + <span> + <Icon + icon={{ + direct: 'envelope', + private: 'lock', + public: 'globe', + unlisted: 'unlock', + }[privacy]} + /> + {' '} + <FormattedMessage {...messages.publish} /> + </span> + ); + case privacy === 'public': + return ( + <span> + <FormattedMessage + {...messages.publishLoud} + values={{ publish: <FormattedMessage {...messages.publish} /> }} + /> + </span> + ); + default: + return <span><FormattedMessage {...messages.publish} /></span>; + } + }()} + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} + onClick={onSubmit} + disabled={disabled || diff < 0} + /> + </div> + ); + }; +} diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js new file mode 100644 index 000000000..f96ea4c5e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js @@ -0,0 +1,86 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import AccountContainer from 'flavours/glitch/containers/account_container'; +import IconButton from 'flavours/glitch/components/icon_button'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; + +// Utils. +import { isRtl } from 'flavours/glitch/util/rtl'; + +// Messages. +const messages = defineMessages({ + cancel: { + defaultMessage: 'Cancel', + id: 'reply_indicator.cancel', + }, +}); + + +export default @injectIntl +class ReplyIndicator extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onCancel: PropTypes.func, + }; + + handleClick = () => { + const { onCancel } = this.props; + if (onCancel) { + onCancel(); + } + } + + // Rendering. + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const account = status.get('account'); + const content = status.get('content'); + const attachments = status.get('media_attachments'); + + // The result. + return ( + <article className='composer--reply'> + <header> + <IconButton + className='cancel' + icon='times' + onClick={this.handleClick} + title={intl.formatMessage(messages.cancel)} + inverted + /> + {account && ( + <AccountContainer + id={account} + small + /> + )} + </header> + <div + className='content' + dangerouslySetInnerHTML={{ __html: content || '' }} + style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }} + /> + {attachments.size > 0 && ( + <AttachmentList + compact + media={attachments} + /> + )} + </article> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js new file mode 100644 index 000000000..5fed1567a --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search.js @@ -0,0 +1,158 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import spring from 'react-motion/lib/spring'; +import { + injectIntl, + FormattedMessage, + defineMessages, +} from 'react-intl'; +import Overlay from 'react-overlays/lib/Overlay'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { focusRoot } from 'flavours/glitch/util/dom_helpers'; +import { searchEnabled } from 'flavours/glitch/util/initial_state'; +import Motion from 'flavours/glitch/util/optional_motion'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, +}); + +class SearchPopout extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + }; + + render () { + const { style } = this.props; + const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />; + return ( + <div style={{ ...style, position: 'absolute', width: 285 }}> + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + <div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> + + <ul> + <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> + <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> + <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> + <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> + </ul> + + {extraInformation} + </div> + )} + </Motion> + </div> + ); + } + +} + +// The component. +export default @injectIntl +class Search extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + submitted: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + expanded: false, + }; + + handleChange = (e) => { + const { onChange } = this.props; + if (onChange) { + onChange(e.target.value); + } + } + + handleClear = (e) => { + const { + onClear, + submitted, + value, + } = this.props; + e.preventDefault(); // Prevents focus change ?? + if (onClear && (submitted || value && value.length)) { + onClear(); + } + } + + handleBlur = () => { + this.setState({ expanded: false }); + } + + handleFocus = () => { + const { onShow } = this.props; + this.setState({ expanded: true }); + if (onShow) { + onShow(); + } + } + + handleKeyUp = (e) => { + const { onSubmit } = this.props; + switch (e.key) { + case 'Enter': + if (onSubmit) { + onSubmit(); + } + break; + case 'Escape': + focusRoot(); + } + } + + render () { + const { intl, value, submitted } = this.props; + const { expanded } = this.state; + const active = value.length > 0 || submitted; + const computedClass = classNames('drawer--search', { active }); + + return ( + <div className={computedClass}> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> + <input + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value || ''} + onChange={this.handleChange} + onKeyUp={this.handleKeyUp} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + /> + </label> + <div + aria-label={intl.formatMessage(messages.placeholder)} + className='icon' + onClick={this.handleClear} + role='button' + tabIndex='0' + > + <Icon icon='search' /> + <Icon icon='times-circle' /> + </div> + <Overlay show={expanded && !active} placement='bottom' target={this}> + <SearchPopout /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js new file mode 100644 index 000000000..69df8cdc9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Hashtag from 'flavours/glitch/components/hashtag'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, +}); + +export default @injectIntl +class SearchResults extends ImmutablePureComponent { + + static propTypes = { + results: ImmutablePropTypes.map.isRequired, + suggestions: ImmutablePropTypes.list.isRequired, + fetchSuggestions: PropTypes.func.isRequired, + dismissSuggestion: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + this.props.fetchSuggestions(); + } + + render() { + const { intl, results, suggestions, dismissSuggestion } = this.props; + + if (results.isEmpty() && !suggestions.isEmpty()) { + return ( + <div className='drawer--results'> + <div className='trends'> + <div className='trends__header'> + <i className='fa fa-user-plus fa-fw' /> + <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> + </div> + + {suggestions && suggestions.map(accountId => ( + <AccountContainer + key={accountId} + id={accountId} + actionIcon='times' + actionTitle={intl.formatMessage(messages.dismissSuggestion)} + onActionClick={dismissSuggestion} + /> + ))} + </div> + </div> + ); + } + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <section> + <h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5> + + {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)} + </section> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <section> + <h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + + {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)} + </section> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <section> + <h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> + + {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </section> + ); + } + + // The result. + return ( + <div className='drawer--results'> + <header className='search-results__header'> + <Icon icon='search' fixedWidth /> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </header> + + {accounts} + {statuses} + {hashtags} + </div> + ); + }; +} diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js new file mode 100644 index 000000000..ec696f9c3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js @@ -0,0 +1,59 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Messages. +const messages = defineMessages({ + localOnly: { + defaultMessage: 'This post is local-only', + id: 'advanced_options.local-only.tooltip', + }, + threadedMode: { + defaultMessage: 'Threaded mode enabled', + id: 'advanced_options.threaded_mode.tooltip', + }, +}); + +// We use an array of tuples here instead of an object because it +// preserves order. +const iconMap = [ + ['do_not_federate', 'home', messages.localOnly], + ['threaded_mode', 'comments', messages.threadedMode], +]; + +export default @injectIntl +class TextareaIcons extends ImmutablePureComponent { + + static propTypes = { + advancedOptions: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + }; + + render () { + const { advancedOptions, intl } = this.props; + return ( + <div className='composer--textarea--icons'> + {advancedOptions ? iconMap.map( + ([key, icon, message]) => advancedOptions.get(key) ? ( + <span + className='textarea_icon' + key={key} + title={intl.formatMessage(message)} + > + <Icon + fullwidth + icon={icon} + /> + </span> + ) : null + ) : null} + </div> + ); + } +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js new file mode 100644 index 000000000..84edf664e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -0,0 +1,131 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; + +const messages = defineMessages({ + description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, +}); + +// The component. +export default @injectIntl +class Upload extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onUndo: PropTypes.func.isRequired, + onDescriptionChange: PropTypes.func.isRequired, + onOpenFocalPoint: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + }; + + state = { + hovered: false, + focused: false, + dirtyDescription: null, + }; + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + handleSubmit = () => { + this.handleInputBlur(); + this.props.onSubmit(this.context.router.history); + } + + handleUndoClick = e => { + e.stopPropagation(); + this.props.onUndo(this.props.media.get('id')); + } + + handleFocalPointClick = e => { + e.stopPropagation(); + this.props.onOpenFocalPoint(this.props.media.get('id')); + } + + handleInputChange = e => { + this.setState({ dirtyDescription: e.target.value }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + handleInputFocus = () => { + this.setState({ focused: true }); + } + + handleClick = () => { + this.setState({ focused: true }); + } + + handleInputBlur = () => { + const { dirtyDescription } = this.state; + + this.setState({ focused: false, dirtyDescription: null }); + + if (dirtyDescription !== null) { + this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); + } + } + + render () { + const { intl, media } = this.props; + const active = this.state.hovered || this.state.focused || isUserTouching(); + const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; + const computedClass = classNames('composer--upload_form--item', { active }); + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + return ( + <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}> + {({ scale }) => ( + <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> + <div className={classNames('composer--upload_form--actions', { active })}> + <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> + {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>} + </div> + + <div className={classNames('composer--upload_form--description', { active })}> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> + <textarea + placeholder={intl.formatMessage(messages.description)} + value={description} + maxLength={420} + onFocus={this.handleInputFocus} + onChange={this.handleInputChange} + onBlur={this.handleInputBlur} + onKeyDown={this.handleKeyDown} + /> + </label> + </div> + </div> + )} + </Motion> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js new file mode 100644 index 000000000..35880ddcc --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -0,0 +1,33 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import UploadContainer from '../containers/upload_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; + +export default class UploadForm extends ImmutablePureComponent { + static propTypes = { + mediaIds: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { mediaIds } = this.props; + + return ( + <div className='composer--upload_form'> + <UploadProgressContainer /> + + {mediaIds.size > 0 && ( + <div className='content'> + {mediaIds.map(id => ( + <UploadContainer id={id} key={id} /> + ))} + </div> + )} + + {!mediaIds.isEmpty() && <SensitiveButtonContainer />} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js new file mode 100644 index 000000000..264c563f2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +export default class UploadProgress extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + progress: PropTypes.number, + }; + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='composer--upload_form--progress'> + <Icon icon='upload' /> + + <div className='message'> + <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + + <div className='backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + (<div className='tracker' style={{ width: `${width}%` }} + />) + } + </Motion> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js new file mode 100644 index 000000000..6ee3640bc --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/warning.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +export default class Warning extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + }; + + render () { + const { message } = this.props; + + return ( + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + <div className='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + {message} + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js new file mode 100644 index 000000000..0e1c328fe --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestAccount from '../components/autosuggest_account'; +import { makeGetAccount } from 'flavours/glitch/selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestAccount); diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js new file mode 100644 index 000000000..2da0770d3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -0,0 +1,136 @@ +import { connect } from 'react-redux'; +import { defineMessages } from 'react-intl'; +import ComposeForm from '../components/compose_form'; +import { + changeCompose, + changeComposeSpoilerText, + changeComposeSpoilerness, + changeComposeVisibility, + clearComposeSuggestions, + fetchComposeSuggestions, + insertEmojiCompose, + mountCompose, + selectComposeSuggestion, + submitCompose, + unmountCompose, + uploadCompose, +} from 'flavours/glitch/actions/compose'; +import { + openModal, +} from 'flavours/glitch/actions/modal'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; + +import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; + +const messages = defineMessages({ + missingDescriptionMessage: { id: 'confirmations.missing_media_description.message', + defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, + missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', + defaultMessage: 'Send anyway' }, +}); + +// State mapping. +function mapStateToProps (state) { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const inReplyTo = state.getIn(['compose', 'in_reply_to']); + const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null; + const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']); + const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null; + let sideArmPrivacy = null; + switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) { + case 'copy': + sideArmPrivacy = replyPrivacy; + break; + case 'restrict': + sideArmPrivacy = sideArmRestrictedPrivacy; + break; + } + sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy; + return { + advancedOptions: state.getIn(['compose', 'advanced_options']), + focusDate: state.getIn(['compose', 'focusDate']), + caretPosition: state.getIn(['compose', 'caretPosition']), + isSubmitting: state.getIn(['compose', 'is_submitting']), + isChangingUpload: state.getIn(['compose', 'is_changing_upload']), + isUploading: state.getIn(['compose', 'is_uploading']), + layout: state.getIn(['local_settings', 'layout']), + media: state.getIn(['compose', 'media_attachments']), + preselectDate: state.getIn(['compose', 'preselectDate']), + privacy: state.getIn(['compose', 'privacy']), + sideArm: sideArmPrivacy, + sensitive: state.getIn(['compose', 'sensitive']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']), + spoilerText: state.getIn(['compose', 'spoiler_text']), + suggestions: state.getIn(['compose', 'suggestions']), + text: state.getIn(['compose', 'text']), + anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, + spoilersAlwaysOn: spoilersAlwaysOn, + mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), + preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), + }; +}; + +// Dispatch mapping. +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange(text) { + dispatch(changeCompose(text)); + }, + + onSubmit(routerHistory) { + dispatch(submitCompose(routerHistory)); + }, + + onClearSuggestions() { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions(token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected(position, token, suggestion, path) { + dispatch(selectComposeSuggestion(position, token, suggestion, path)); + }, + + onChangeSpoilerText(text) { + dispatch(changeComposeSpoilerText(text)); + }, + + onPaste(files) { + dispatch(uploadCompose(files)); + }, + + onPickEmoji(position, emoji) { + dispatch(insertEmojiCompose(position, emoji)); + }, + + onChangeSpoilerness() { + dispatch(changeComposeSpoilerness()); + }, + + onChangeVisibility(value) { + dispatch(changeComposeVisibility(value)); + }, + + onMount() { + dispatch(mountCompose()); + }, + + onUnmount() { + dispatch(unmountCompose()); + }, + + onMediaDescriptionConfirm(routerHistory) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.missingDescriptionMessage), + confirm: intl.formatMessage(messages.missingDescriptionConfirm), + onConfirm: () => dispatch(submitCompose(routerHistory)), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), + })); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js new file mode 100644 index 000000000..ce1dea319 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js @@ -0,0 +1,21 @@ +import { openModal } from 'flavours/glitch/actions/modal'; +import { connect } from 'react-redux'; +import Header from '../components/header'; + +const mapStateToProps = state => { + return { + columns: state.getIn(['settings', 'columns']), + unreadNotifications: state.getIn(['notifications', 'unread']), + showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), + }; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onSettingsClick (e) { + e.preventDefault(); + e.stopPropagation(); + dispatch(openModal('SETTINGS', {})); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Header); diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js new file mode 100644 index 000000000..eb630ffbb --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import NavigationBar from '../components/navigation_bar'; +import { me } from 'flavours/glitch/util/initial_state'; + +const mapStateToProps = state => { + return { + account: state.getIn(['accounts', me]), + }; +}; + +export default connect(mapStateToProps)(NavigationBar); diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js new file mode 100644 index 000000000..2ac7ab8d8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js @@ -0,0 +1,52 @@ +import { connect } from 'react-redux'; +import Options from '../components/options'; +import { + changeComposeAdvancedOption, +} from 'flavours/glitch/actions/compose'; +import { addPoll, removePoll } from 'flavours/glitch/actions/compose'; +import { closeModal, openModal } from 'flavours/glitch/actions/modal'; + +function mapStateToProps (state) { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const poll = state.getIn(['compose', 'poll']); + const media = state.getIn(['compose', 'media_attachments']); + return { + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), + resetFileKey: state.getIn(['compose', 'resetFileKey']), + hasPoll: !!poll, + allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true), + hasMedia: media && !!media.size, + allowPoll: !(media && !!media.size), + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + + onChangeAdvancedOption(option, value) { + dispatch(changeComposeAdvancedOption(option, value)); + }, + + onTogglePoll() { + dispatch((_, getState) => { + if (getState().getIn(['compose', 'poll'])) { + dispatch(removePoll()); + } else { + dispatch(addPoll()); + } + }); + }, + + onDoodleOpen() { + dispatch(openModal('DOODLE', { noEsc: true })); + }, + + onModalClose() { + dispatch(closeModal()); + }, + + onModalOpen(props) { + dispatch(openModal('ACTIONS', props)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Options); diff --git a/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js new file mode 100644 index 000000000..e87e58771 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js @@ -0,0 +1,48 @@ +import { connect } from 'react-redux'; +import PollForm from '../components/poll_form'; +import { addPollOption, removePollOption, changePollOption, changePollSettings } from 'flavours/glitch/actions/compose'; +import { + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, +} from 'flavours/glitch/actions/compose'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['compose', 'suggestions']), + options: state.getIn(['compose', 'poll', 'options']), + expiresIn: state.getIn(['compose', 'poll', 'expires_in']), + isMultiple: state.getIn(['compose', 'poll', 'multiple']), +}); + +const mapDispatchToProps = dispatch => ({ + onAddOption(title) { + dispatch(addPollOption(title)); + }, + + onRemoveOption(index) { + dispatch(removePollOption(index)); + }, + + onChangeOption(index, title) { + dispatch(changePollOption(index, title)); + }, + + onChangeSettings(expiresIn, isMultiple) { + dispatch(changePollSettings(expiresIn, isMultiple)); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId, path) { + dispatch(selectComposeSuggestion(position, token, accountId, path)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PollForm); diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js new file mode 100644 index 000000000..395a9aa5b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +function makeMapStateToProps (state) { + const inReplyTo = state.getIn(['compose', 'in_reply_to']); + + return { + status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null, + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js new file mode 100644 index 000000000..8f4bfcf08 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearch, + submitSearch, + showSearch, +} from 'flavours/glitch/actions/search'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearch()); + }, + + onSubmit () { + dispatch(submitSearch()); + }, + + onShow () { + dispatch(showSearch()); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js new file mode 100644 index 000000000..f9637861a --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; +import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']), + suggestions: state.getIn(['suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchSuggestions: () => dispatch(fetchSuggestions()), + dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js new file mode 100644 index 000000000..8f163979f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { changeComposeSensitivity } from 'flavours/glitch/actions/compose'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, + unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, +}); + +const mapStateToProps = state => { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const spoilerText = state.getIn(['compose', 'spoiler_text']); + return { + active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0), + disabled: state.getIn(['compose', 'spoiler']), + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { active, disabled, onClick, intl } = this.props; + + return ( + <div className='compose-form__sensitive-button'> + <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> + <Icon icon='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> + </button> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js new file mode 100644 index 000000000..d6bff63ac --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import Upload from '../components/upload'; +import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { submitCompose } from 'flavours/glitch/actions/compose'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = dispatch => ({ + + onUndo: id => { + dispatch(undoUploadCompose(id)); + }, + + onDescriptionChange: (id, description) => { + dispatch(changeUploadCompose(id, { description })); + }, + + onOpenFocalPoint: id => { + dispatch(openModal('FOCAL_POINT', { id })); + }, + + onSubmit (router) { + dispatch(submitCompose(router)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Upload); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js new file mode 100644 index 000000000..a6798bf51 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import UploadForm from '../components/upload_form'; + +const mapStateToProps = state => ({ + mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), +}); + +export default connect(mapStateToProps)(UploadForm); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js new file mode 100644 index 000000000..0cfee96da --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = state => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']), +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js new file mode 100644 index 000000000..fdd21f114 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Warning from '../components/warning'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { me } from 'flavours/glitch/util/initial_state'; + +const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; + +const mapStateToProps = state => ({ + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), + hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])), + directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', +}); + +const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { + if (needsLockWarning) { + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } + + if (hashtagWarning) { + return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />; + } + + if (directMessageWarning) { + const message = ( + <span> + <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a> + </span> + ); + + return <Warning message={message} />; + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLockWarning: PropTypes.bool, + hashtagWarning: PropTypes.bool, + directMessageWarning: PropTypes.bool, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js new file mode 100644 index 000000000..a7795a04d --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/index.js @@ -0,0 +1,84 @@ +import React from 'react'; +import ComposeFormContainer from './containers/compose_form_container'; +import NavigationContainer from './containers/navigation_container'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { injectIntl, defineMessages } from 'react-intl'; +import classNames from 'classnames'; +import SearchContainer from './containers/search_container'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import SearchResultsContainer from './containers/search_results_container'; +import { me, mascot } from 'flavours/glitch/util/initial_state'; +import { cycleElefriendCompose } from 'flavours/glitch/actions/compose'; +import HeaderContainer from './containers/header_container'; + +const messages = defineMessages({ + compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' }, +}); + +const mapStateToProps = (state, ownProps) => ({ + elefriend: state.getIn(['compose', 'elefriend']), + showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onClickElefriend () { + dispatch(cycleElefriendCompose()); + }, +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class Compose extends React.PureComponent { + static propTypes = { + multiColumn: PropTypes.bool, + showSearch: PropTypes.bool, + isSearchPage: PropTypes.bool, + elefriend: PropTypes.number, + onClickElefriend: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + render () { + const { + elefriend, + intl, + multiColumn, + onClickElefriend, + isSearchPage, + showSearch, + } = this.props; + const computedClass = classNames('drawer', `mbstobon-${elefriend}`); + + return ( + <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}> + {multiColumn && <HeaderContainer />} + + {(multiColumn || isSearchPage) && <SearchContainer />} + + <div className='drawer__pager'> + {!isSearchPage && <div className='drawer__inner'> + <NavigationContainer /> + <ComposeFormContainer /> + {multiColumn && ( + <div className='drawer__inner__mastodon'> + {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />} + </div> + )} + </div>} + + <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => ( + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + )} + </Motion> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js new file mode 100644 index 000000000..5adb44f2c --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingText from '../../../components/setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..6385d30a4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (path, checked) { + dispatch(changeSetting(['direct', ...path], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js new file mode 100644 index 000000000..dc7e0534d --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { expandDirectTimeline } from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectDirectStream } from 'flavours/glitch/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(expandDirectTimeline()); + this.disconnect = dispatch(connectDirectStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + this.props.dispatch(expandDirectTimeline({ maxId })); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> + <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' + onLoadMore={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/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js new file mode 100644 index 000000000..3b29e2a26 --- /dev/null +++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import LoadingIndicator from '../../components/loading_indicator'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import DomainContainer from '../../containers/domain_container'; +import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { debounce } from 'lodash'; +import ScrollableList from '../../components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +const mapStateToProps = state => ({ + domains: state.getIn(['domain_lists', 'blocks', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Blocks extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + domains: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchDomainBlocks()); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandDomainBlocks()); + }, 300, { leading: true }); + + render () { + const { intl, domains } = this.props; + + if (!domains) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}> + {domains.map(domain => + <DomainContainer key={domain} domain={domain} /> + )} + </ScrollableList> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js new file mode 100644 index 000000000..a78117971 --- /dev/null +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -0,0 +1,460 @@ +import { connect } from 'react-redux'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; +import { useEmoji } from 'flavours/glitch/actions/emojis'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/util/async-components'; +import Overlay from 'react-overlays/lib/Overlay'; +import classNames from 'classnames'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import detectPassiveEvents from 'detect-passive-events'; +import { buildCustomEmojis } from 'flavours/glitch/util/emoji'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + state => state.get('custom_emojis'), +], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { + const aShort = a.get('shortcode').toLowerCase(); + const bShort = b.get('shortcode').toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort ) { + return 1; + } else { + return 0; + } +})); + +const mapStateToProps = state => ({ + custom_emojis: getCustomEmojis(state), + skinTone: state.getIn(['settings', 'skinTone']), + frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), +}); + +const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ + onSkinTone: skinTone => { + dispatch(changeSetting(['skinTone'], skinTone)); + }, + + onPickEmoji: emoji => { + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }, +}); + +const assetHost = process.env.CDN_HOST || ''; +let EmojiPicker, Emoji; // load asynchronously + +const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +const categoriesSort = [ + 'recent', + 'custom', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +]; + +class ModifierPickerMenu extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onSelect: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + }; + + handleClick = e => { + this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.active) { + this.attachListeners(); + } else { + this.removeListeners(); + } + } + + componentWillUnmount () { + this.removeListeners(); + } + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + attachListeners () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + removeListeners () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + render () { + const { active } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> + <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> + </div> + ); + } + +} + +class ModifierPicker extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + modifier: PropTypes.number, + onChange: PropTypes.func, + onClose: PropTypes.func, + onOpen: PropTypes.func, + }; + + handleClick = () => { + if (this.props.active) { + this.props.onClose(); + } else { + this.props.onOpen(); + } + } + + handleSelect = modifier => { + this.props.onChange(modifier); + this.props.onClose(); + } + + render () { + const { active, modifier } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers'> + <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} /> + <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> + </div> + ); + } + +} + +@injectIntl +class EmojiPickerMenu extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, + onClose: PropTypes.func.isRequired, + onPick: PropTypes.func.isRequired, + style: PropTypes.object, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + intl: PropTypes.object.isRequired, + skinTone: PropTypes.number.isRequired, + onSkinTone: PropTypes.func.isRequired, + }; + + static defaultProps = { + style: {}, + loading: true, + frequentlyUsedEmojis: [], + }; + + state = { + modifierOpen: false, + placement: null, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + getI18n = () => { + const { intl } = this.props; + + return { + search: intl.formatMessage(messages.emoji_search), + notfound: intl.formatMessage(messages.emoji_not_found), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }; + } + + handleClick = emoji => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + + this.props.onClose(); + this.props.onPick(emoji); + } + + handleModifierOpen = () => { + this.setState({ modifierOpen: true }); + } + + handleModifierClose = () => { + this.setState({ modifierOpen: false }); + } + + handleModifierChange = modifier => { + this.props.onSkinTone(modifier); + } + + render () { + const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; + + if (loading) { + return <div style={{ width: 299 }} />; + } + + const title = intl.formatMessage(messages.emoji); + const { modifierOpen } = this.state; + + return ( + <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> + <EmojiPicker + perLine={8} + emojiSize={22} + sheetSize={32} + custom={buildCustomEmojis(custom_emojis)} + color='' + emoji='' + set='twitter' + title={title} + i18n={this.getI18n()} + onClick={this.handleClick} + include={categoriesSort} + recent={frequentlyUsedEmojis} + skin={skinTone} + showPreview={false} + backgroundImageFn={backgroundImageFn} + autoFocus + emojiTooltip + /> + + <ModifierPicker + active={modifierOpen} + modifier={skinTone} + onOpen={this.handleModifierOpen} + onClose={this.handleModifierClose} + onChange={this.handleModifierChange} + /> + </div> + ); + } + +} + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class EmojiPickerDropdown extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + intl: PropTypes.object.isRequired, + onPickEmoji: PropTypes.func.isRequired, + onSkinTone: PropTypes.func.isRequired, + skinTone: PropTypes.number.isRequired, + }; + + state = { + active: false, + loading: false, + }; + + setRef = (c) => { + this.dropdown = c; + } + + onShowDropdown = ({ target }) => { + this.setState({ active: true }); + + if (!EmojiPicker) { + this.setState({ loading: true }); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + + this.setState({ loading: false }); + }).catch(() => { + this.setState({ loading: false }); + }); + } + + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + } + + onHideDropdown = () => { + this.setState({ active: false }); + } + + onToggle = (e) => { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (this.state.active) { + this.onHideDropdown(); + } else { + this.onShowDropdown(e); + } + } + } + + handleKeyDown = e => { + if (e.key === 'Escape') { + this.onHideDropdown(); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + render () { + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const title = intl.formatMessage(messages.emoji); + const { active, loading, placement } = this.state; + + return ( + <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> + <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> + <img + className={classNames('emojione', { 'pulse-loading': active && loading })} + alt='🙂' + src={`${assetHost}/emoji/1f602.svg`} + /> + </div> + + <Overlay show={active} placement={placement} target={this.findTarget}> + <EmojiPickerMenu + custom_emojis={this.props.custom_emojis} + loading={loading} + onClose={this.onHideDropdown} + onPick={onPickEmoji} + onSkinTone={onSkinTone} + skinTone={skinTone} + frequentlyUsedEmojis={frequentlyUsedEmojis} + /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js new file mode 100644 index 000000000..32bf4e71a --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import StatusList from 'flavours/glitch/components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { debounce } from 'lodash'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Favourites extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandFavouritedStatuses()); + }, 300, { leading: true }) + + render () { + const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}> + <ColumnHeader + icon='star' + title={intl.formatMessage(messages.heading)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + /> + + <StatusList + trackScroll={!pinned} + statusIds={statusIds} + scrollKey={`favourited_statuses-${columnId}`} + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/favourites/index.js b/app/javascript/flavours/glitch/features/favourites/index.js new file mode 100644 index 000000000..eb86636c3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourites/index.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { fetchFavourites } from 'flavours/glitch/actions/interactions'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.favourited_by', defaultMessage: 'Favourited by' }, +}); + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Favourites extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchFavourites(nextProps.params.statusId)); + } + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='star' + title={intl.formatMessage(messages.heading)} + onClick={this.handleHeaderClick} + showBackButton + /> + + <ScrollContainer scrollKey='favourites' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js new file mode 100644 index 000000000..dead0753f --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from 'flavours/glitch/components/permalink'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +@injectIntl +export default class AccountAuthorize extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, account, onAuthorize, onReject } = this.props; + const content = { __html: account.get('note_emojified') }; + + return ( + <div className='account-authorize__wrapper'> + <div className='account-authorize'> + <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> + <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__header__content' dangerouslySetInnerHTML={content} /> + </div> + + <div className='account--panel'> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js b/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js new file mode 100644 index 000000000..693e98e8c --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import AccountAuthorize from '../components/account_authorize'; +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { id }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(id)); + }, + + onReject () { + dispatch(rejectFollowRequest(id)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/javascript/flavours/glitch/features/follow_requests/index.js b/app/javascript/flavours/glitch/features/follow_requests/index.js new file mode 100644 index 000000000..bce6338ea --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import AccountAuthorizeContainer from './containers/account_authorize_container'; +import { fetchFollowRequests, expandFollowRequests } from 'flavours/glitch/actions/accounts'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class FollowRequests extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchFollowRequests()); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowRequests()); + } + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column name='follow-requests'> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <ScrollContainer scrollKey='follow_requests' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js new file mode 100644 index 000000000..2e47ab9b9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowers, + expandFollowers, +} from 'flavours/glitch/actions/accounts'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; +import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; +import LoadMore from 'flavours/glitch/components/load_more'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; + +const mapStateToProps = (state, props) => ({ + isAccount: !!state.getIn(['accounts', props.params.accountId]), + accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), +}); + +@connect(mapStateToProps) +export default class Followers extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isAccount: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowers(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchFollowers(nextProps.params.accountId)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { + this.props.dispatch(expandFollowers(this.props.params.accountId)); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.dispatch(expandFollowers(this.props.params.accountId)); + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + setRef = c => { + this.column = c; + } + + render () { + const { accountIds, hasMore, isAccount } = this.props; + + if (!isAccount) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + + let loadMore = null; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + if (hasMore) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> + + <ScrollContainer scrollKey='followers' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='followers'> + <HeaderContainer accountId={this.props.params.accountId} hideTabs /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + {loadMore} + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js new file mode 100644 index 000000000..ad1445f3a --- /dev/null +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowing, + expandFollowing, +} from 'flavours/glitch/actions/accounts'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; +import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container'; +import LoadMore from 'flavours/glitch/components/load_more'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; + +const mapStateToProps = (state, props) => ({ + isAccount: !!state.getIn(['accounts', props.params.accountId]), + accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), +}); + +@connect(mapStateToProps) +export default class Following extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isAccount: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchFollowing(this.props.params.accountId)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchFollowing(nextProps.params.accountId)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { + this.props.dispatch(expandFollowing(this.props.params.accountId)); + } + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.dispatch(expandFollowing(this.props.params.accountId)); + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + setRef = c => { + this.column = c; + } + + render () { + const { accountIds, hasMore, isAccount } = this.props; + + if (!isAccount) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + + let loadMore = null; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + if (hasMore) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> + + <ScrollContainer scrollKey='following' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='following'> + <HeaderContainer accountId={this.props.params.accountId} hideTabs /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + {loadMore} + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/generic_not_found/index.js b/app/javascript/flavours/glitch/features/generic_not_found/index.js new file mode 100644 index 000000000..d01a1ba47 --- /dev/null +++ b/app/javascript/flavours/glitch/features/generic_not_found/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Column from 'flavours/glitch/features/ui/components/column'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; + +const GenericNotFound = () => ( + <Column> + <MissingIndicator /> + </Column> +); + +export default GenericNotFound; diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js new file mode 100644 index 000000000..7b645c9d0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/index.js @@ -0,0 +1,190 @@ +import React from 'react'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; +import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import { openModal } from 'flavours/glitch/actions/modal'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me, invitesEnabled, version } from 'flavours/glitch/util/initial_state'; +import { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; +import { List as ImmutableList } from 'immutable'; +import { createSelector } from 'reselect'; +import { fetchLists } from 'flavours/glitch/actions/lists'; +import { preferencesLink, profileLink, signOutLink } from 'flavours/glitch/util/backend_links'; + +const messages = defineMessages({ + heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + 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' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, + 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' }, + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' }, + misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' }, + menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, +}); + +const makeMapStateToProps = () => { + const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); + }); + + const mapStateToProps = state => ({ + lists: getOrderedLists(state), + myAccount: state.getIn(['accounts', me]), + columns: state.getIn(['settings', 'columns']), + unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, + unreadNotifications: state.getIn(['notifications', 'unread']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + fetchFollowRequests: () => dispatch(fetchFollowRequests()), + fetchLists: () => dispatch(fetchLists()), + openSettings: () => dispatch(openModal('SETTINGS', {})), +}); + +const badgeDisplay = (number, limit) => { + if (number === 0) { + return undefined; + } else if (limit && number >= limit) { + return `${limit}+`; + } else { + return number; + } +}; + +@connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +export default class GettingStarted extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + columns: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + fetchFollowRequests: PropTypes.func.isRequired, + unreadFollowRequests: PropTypes.number, + unreadNotifications: PropTypes.number, + lists: ImmutablePropTypes.list, + fetchLists: PropTypes.func.isRequired, + openSettings: PropTypes.func.isRequired, + }; + + componentWillMount () { + this.props.fetchLists(); + } + + componentDidMount () { + const { myAccount, fetchFollowRequests } = this.props; + + if (myAccount.get('locked')) { + fetchFollowRequests(); + } + } + + render () { + const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications, lists, openSettings } = this.props; + + const navItems = []; + let listItems = []; + + if (multiColumn) { + if (!columns.find(item => item.get('id') === 'HOME')) { + navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />); + } + + if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { + navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} badge={badgeDisplay(unreadNotifications)} to='/notifications' />); + } + + if (!columns.find(item => item.get('id') === 'COMMUNITY')) { + navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />); + } + + if (!columns.find(item => item.get('id') === 'PUBLIC')) { + navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />); + } + } + + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />); + } + + if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) { + navItems.push(<ColumnLink key='5' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />); + } + + if (myAccount.get('locked')) { + navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); + } + + navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); + + listItems = listItems.concat([ + <div key='8'> + <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> + {lists.map(list => + <ColumnLink key={(9 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> + )} + </div>, + ]); + + return ( + <Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile> + <div className='scrollable optionally-scrollable'> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> + {navItems} + <ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} /> + {listItems} + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> + { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> } + <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' /> + </div> + + <div className='getting-started__footer'> + <ul> + {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} + <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> + <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> + <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> + <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a></li> + </ul> + + <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: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>, + Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} + /> + </p> + </div> + </div> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js new file mode 100644 index 000000000..ee4452472 --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; +import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { connect } from 'react-redux'; + +const messages = defineMessages({ + heading: { id: 'column.heading', defaultMessage: 'Misc' }, + subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, + 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' }, + info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, + keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, + featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' }, +}); + +@connect() +@injectIntl +export default class gettingStartedMisc extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + openOnboardingModal = (e) => { + this.props.dispatch(openModal('ONBOARDING')); + } + + openFeaturedAccountsModal = (e) => { + this.props.dispatch(openModal('PINNED_ACCOUNTS_EDITOR')); + } + + render () { + const { intl } = this.props; + + let i = 1; + + return ( + <Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <div className='scrollable'> + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + <ColumnLink key='{i++}' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> + <ColumnLink key='{i++}' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' /> + <ColumnLink key='{i++}' icon='users' text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} /> + <ColumnLink key='{i++}' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' /> + <ColumnLink key='{i++}' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> + <ColumnLink key='{i++}' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' /> + <ColumnLink key='{i++}' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' /> + <ColumnLink key='{i++}' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink key='{i++}' icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} /> + </div> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js new file mode 100644 index 000000000..dc0ffee85 --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js @@ -0,0 +1,113 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import AsyncSelect from 'react-select/lib/Async'; + +const messages = defineMessages({ + placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' }, + noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onLoad: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: this.hasTags(), + }; + + hasTags () { + return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true); + } + + tags (mode) { + let tags = this.props.settings.getIn(['tags', mode]) || []; + + if (tags.toJSON) { + return tags.toJSON(); + } else { + return tags; + } + }; + + onSelect = mode => value => this.props.onChange(['tags', mode], value); + + onToggle = () => { + if (this.state.open && this.hasTags()) { + this.props.onChange('tags', {}); + } + + this.setState({ open: !this.state.open }); + }; + + noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions); + + modeSelect (mode) { + return ( + <div className='column-settings__row'> + <span className='column-settings__section'> + {this.modeLabel(mode)} + </span> + + <AsyncSelect + isMulti + autoFocus + value={this.tags(mode)} + onChange={this.onSelect(mode)} + loadOptions={this.props.onLoad} + className='column-select__container' + classNamePrefix='column-select' + name='tags' + placeholder={this.props.intl.formatMessage(messages.placeholder)} + noOptionsMessage={this.noOptionsMessage} + /> + </div> + ); + } + + modeLabel (mode) { + switch(mode) { + case 'any': + return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />; + case 'all': + return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />; + case 'none': + return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />; + default: + return ''; + } + }; + + render () { + return ( + <div> + <div className='column-settings__row'> + <div className='setting-toggle'> + <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} /> + + <span className='setting-toggle__label'> + <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> + </span> + </div> + </div> + + {this.state.open && ( + <div className='column-settings__hashtags'> + {this.modeSelect('any')} + {this.modeSelect('all')} + {this.modeSelect('none')} + </div> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..757cd48fb --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeColumnParams } from 'flavours/glitch/actions/columns'; +import api from 'flavours/glitch/util/api'; + +const mapStateToProps = (state, { columnId }) => { + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === columnId); + + if (!(columnId && index >= 0)) { + return {}; + } + + return { settings: columns.get(index).get('params') }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => ({ + onChange (key, value) { + dispatch(changeColumnParams(columnId, key, value)); + }, + + onLoad (value) { + return api().get('/api/v2/search', { params: { q: value } }).then(response => { + return (response.data.hashtags || []).map((tag) => { + return { value: tag.name, label: `#${tag.name}` }; + }); + }); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js new file mode 100644 index 000000000..21efaceea --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -0,0 +1,163 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { FormattedMessage } from 'react-intl'; +import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; +import { isEqual } from 'lodash'; + +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, +}); + +@connect(mapStateToProps) +export default class HashtagTimeline extends React.PureComponent { + + disconnects = []; + + static propTypes = { + params: PropTypes.object.isRequired, + columnId: PropTypes.string, + dispatch: PropTypes.func.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HASHTAG', { id: this.props.params.id })); + } + } + + title = () => { + let title = [this.props.params.id]; + + if (this.additionalFor('any')) { + title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />); + } + + if (this.additionalFor('all')) { + title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />); + } + + if (this.additionalFor('none')) { + title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />); + } + + return title; + } + + additionalFor = (mode) => { + const { tags } = this.props.params; + + if (tags && (tags[mode] || []).length > 0) { + return tags[mode].map(tag => tag.value).join('/'); + } else { + return ''; + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + _subscribe (dispatch, id, tags = {}) { + let any = (tags.any || []).map(tag => tag.value); + let all = (tags.all || []).map(tag => tag.value); + let none = (tags.none || []).map(tag => tag.value); + + [id, ...any].map(tag => { + this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => { + let tags = status.tags.map(tag => tag.name); + + return all.filter(tag => tags.includes(tag)).length === all.length && + none.filter(tag => tags.includes(tag)).length === 0; + }))); + }); + } + + _unsubscribe () { + this.disconnects.map(disconnect => disconnect()); + this.disconnects = []; + } + + componentDidMount () { + const { dispatch } = this.props; + const { id, tags } = this.props.params; + + this._subscribe(dispatch, id, tags); + dispatch(expandHashtagTimeline(id, { tags })); + } + + componentWillReceiveProps (nextProps) { + const { dispatch, params } = this.props; + const { id, tags } = nextProps.params; + + if (id !== params.id || !isEqual(tags, params.tags)) { + this._unsubscribe(); + this._subscribe(dispatch, id, tags); + this.props.dispatch(clearTimeline(`hashtag:${id}`)); + this.props.dispatch(expandHashtagTimeline(id, { tags })); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { id, tags } = this.props.params; + this.props.dispatch(expandHashtagTimeline(id, { maxId, tags })); + } + + render () { + const { hasUnread, columnId, multiColumn } = this.props; + const { id } = this.props.params; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='hashtag' label={`#${id}`}> + <ColumnHeader + icon='hashtag' + active={hasUnread} + title={this.title()} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + showBackButton + > + {columnId && <ColumnSettingsContainer columnId={columnId} />} + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`hashtag_timeline-${columnId}`} + timelineId={`hashtag:${id}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js new file mode 100644 index 000000000..d7692513e --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle'; +import SettingText from 'flavours/glitch/components/setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +@injectIntl +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( + <div> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> + </div> + + <div className='column-settings__row'> + <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> + + <div className='column-settings__row'> + <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show DMs' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText prefix='home_timeline' settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..16747151b --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (path, checked) { + dispatch(changeSetting(['home', ...path], checked)); + }, + + onSave () { + dispatch(saveSettings()); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js new file mode 100644 index 000000000..8eb79fa60 --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -0,0 +1,125 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router-dom'; + +const messages = defineMessages({ + title: { id: 'column.home', defaultMessage: 'Home' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, + isPartial: state.getIn(['timelines', 'home', 'isPartial']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class HomeTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + isPartial: PropTypes.bool, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HOME', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + this.props.dispatch(expandHomeTimeline({ maxId })); + } + + componentDidMount () { + this._checkIfReloadNeeded(false, this.props.isPartial); + } + + componentDidUpdate (prevProps) { + this._checkIfReloadNeeded(prevProps.isPartial, this.props.isPartial); + } + + componentWillUnmount () { + this._stopPolling(); + } + + _checkIfReloadNeeded (wasPartial, isPartial) { + const { dispatch } = this.props; + + if (wasPartial === isPartial) { + return; + } else if (!wasPartial && isPartial) { + this.polling = setInterval(() => { + dispatch(expandHomeTimeline()); + }, 3000); + } else if (wasPartial && !isPartial) { + this._stopPolling(); + } + } + + _stopPolling () { + if (this.polling) { + clearInterval(this.polling); + this.polling = null; + } + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='home' + 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={`home_timeline-${columnId}`} + onLoadMore={this.handleLoadMore} + timelineId='home' + emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js new file mode 100644 index 000000000..2935a6021 --- /dev/null +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js @@ -0,0 +1,123 @@ +import React from 'react'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' }, +}); + +const mapStateToProps = state => ({ + collapseEnabled: state.getIn(['local_settings', 'collapsed', 'enabled']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class KeyboardShortcuts extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + collapseEnabled: PropTypes.bool, + }; + + render () { + const { intl, collapseEnabled } = this.props; + + return ( + <Column icon='question' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <div className='keyboard-shortcuts scrollable optionally-scrollable'> + <table> + <thead> + <tr> + <th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th> + <th><FormattedMessage id='keyboard_shortcuts.description' defaultMessage='Description' /></th> + </tr> + </thead> + <tbody> + <tr> + <td><kbd>r</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td> + </tr> + <tr> + <td><kbd>m</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td> + </tr> + <tr> + <td><kbd>p</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></td> + </tr> + <tr> + <td><kbd>f</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favourite' /></td> + </tr> + <tr> + <td><kbd>b</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td> + </tr> + <tr> + <td><kbd>d</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td> + </tr> + <tr> + <td><kbd>enter</kbd>, <kbd>o</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td> + </tr> + <tr> + <td><kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> + </tr> + {collapseEnabled && ( + <tr> + <td><kbd>shift</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_collapse' defaultMessage='to collapse/uncollapse toots' /></td> + </tr> + )} + <tr> + <td><kbd>up</kbd>, <kbd>k</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td> + </tr> + <tr> + <td><kbd>down</kbd>, <kbd>j</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td> + </tr> + <tr> + <td><kbd>1</kbd>-<kbd>9</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td> + </tr> + <tr> + <td><kbd>n</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td> + </tr> + <tr> + <td><kbd>alt</kbd>+<kbd>n</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td> + </tr> + <tr> + <td><kbd>backspace</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td> + </tr> + <tr> + <td><kbd>s</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td> + </tr> + <tr> + <td><kbd>esc</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td> + </tr> + <tr> + <td><kbd>?</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td> + </tr> + </tbody> + </table> + </div> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_adder/components/account.js b/app/javascript/flavours/glitch/features/list_adder/components/account.js new file mode 100644 index 000000000..1369aac07 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_adder/components/account.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import { injectIntl } from 'react-intl'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + + +export default @connect(makeMapStateToProps) +@injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + return ( + <div className='account'> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_adder/components/list.js b/app/javascript/flavours/glitch/features/list_adder/components/list.js new file mode 100644 index 000000000..cb8eb7d7a --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_adder/components/list.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import { removeFromListAdder, addToListAdder } from '../../../actions/lists'; + +const messages = defineMessages({ + remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, + add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, +}); + +const MapStateToProps = (state, { listId, added }) => ({ + list: state.get('lists').get(listId), + added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added, +}); + +const mapDispatchToProps = (dispatch, { listId }) => ({ + onRemove: () => dispatch(removeFromListAdder(listId)), + onAdd: () => dispatch(addToListAdder(listId)), +}); + +export default @connect(MapStateToProps, mapDispatchToProps) +@injectIntl +class List extends ImmutablePureComponent { + + static propTypes = { + list: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { list, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />; + } + + return ( + <div className='list'> + <div className='list__wrapper'> + <div className='list__display-name'> + <i className='fa fa-fw fa-list-ul column-link__icon' /> + {list.get('title')} + </div> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_adder/index.js b/app/javascript/flavours/glitch/features/list_adder/index.js new file mode 100644 index 000000000..cb8a15e8c --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_adder/index.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl } from 'react-intl'; +import { setupListAdder, resetListAdder } from '../../actions/lists'; +import { createSelector } from 'reselect'; +import List from './components/list'; +import Account from './components/account'; +import NewListForm from '../lists/components/new_list_form'; +// hack + +const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + listIds: getOrderedLists(state).map(list=>list.get('id')), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: accountId => dispatch(setupListAdder(accountId)), + onReset: () => dispatch(resetListAdder()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class ListAdder extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + listIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, accountId } = this.props; + onInitialize(accountId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountId, listIds } = this.props; + + return ( + <div className='modal-root__modal list-adder'> + <div className='list-adder__account'> + <Account accountId={accountId} /> + </div> + + <NewListForm /> + + + <div className='list-adder__lists'> + {listIds.map(ListId => <List key={ListId} listId={ListId} />)} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_editor/components/account.js b/app/javascript/flavours/glitch/features/list_editor/components/account.js new file mode 100644 index 000000000..71a8b7673 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/account.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, + add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, +}); + +export default class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { account, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />; + } + + return ( + <div className='account'> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </div> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js new file mode 100644 index 000000000..24aaf82ac --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'lists.edit.submit', defaultMessage: 'Change title' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'title']), + disabled: !state.getIn(['listEditor', 'isChanged']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeListEditorTitle(value)), + onSubmit: () => dispatch(submitListEditor(false)), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class ListForm extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + } + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + } + + handleClick = () => { + this.props.onSubmit(); + } + + render () { + const { value, disabled, intl } = this.props; + + const title = intl.formatMessage(messages.title); + + return ( + <form className='column-inline-form' onSubmit={this.handleSubmit}> + <input + className='setting-text' + value={value} + onChange={this.handleChange} + /> + + <IconButton + disabled={disabled} + icon='check' + title={title} + onClick={this.handleClick} + /> + </form> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_editor/components/search.js b/app/javascript/flavours/glitch/features/list_editor/components/search.js new file mode 100644 index 000000000..280632652 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/search.js @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages } from 'react-intl'; +import classNames from 'classnames'; + +const messages = defineMessages({ + search: { id: 'lists.search', defaultMessage: 'Search among people you follow' }, +}); + +export default class Search extends React.PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + } + + handleKeyUp = e => { + if (e.keyCode === 13) { + this.props.onSubmit(this.props.value); + } + } + + handleClear = () => { + this.props.onClear(); + } + + render () { + const { value, intl } = this.props; + const hasValue = value.length > 0; + + return ( + <div className='list-editor__search search'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span> + + <input + className='search__input' + type='text' + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyUp} + placeholder={intl.formatMessage(messages.search)} + /> + </label> + + <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> + <i className={classNames('fa fa-search', { active: !hasValue })} /> + <i aria-label={intl.formatMessage(messages.search)} className={classNames('fa fa-times-circle', { active: hasValue })} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js new file mode 100644 index 000000000..782eb42f3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import { injectIntl } from 'react-intl'; +import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists'; +import Account from '../components/account'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(removeFromListEditor(accountId)), + onAdd: () => dispatch(addToListEditor(accountId)), +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js new file mode 100644 index 000000000..5af20efbd --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl } from 'react-intl'; +import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchListSuggestions(value)), + onClear: () => dispatch(clearListSuggestions()), + onChange: value => dispatch(changeListSuggestions(value)), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search)); diff --git a/app/javascript/flavours/glitch/features/list_editor/index.js b/app/javascript/flavours/glitch/features/list_editor/index.js new file mode 100644 index 000000000..5f552b113 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/index.js @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl } from 'react-intl'; +import { setupListEditor, clearListSuggestions, resetListEditor } from 'flavours/glitch/actions/lists'; +import AccountContainer from './containers/account_container'; +import SearchContainer from './containers/search_container'; +import EditListForm from './components/edit_list_form'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +const mapStateToProps = state => ({ + accountIds: state.getIn(['listEditor', 'accounts', 'items']), + searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: listId => dispatch(setupListEditor(listId)), + onClear: () => dispatch(clearListSuggestions()), + onReset: () => dispatch(resetListEditor()), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class ListEditor extends ImmutablePureComponent { + + static propTypes = { + listId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list.isRequired, + searchAccountIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, listId } = this.props; + onInitialize(listId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountIds, searchAccountIds, onClear } = this.props; + const showSearch = searchAccountIds.size > 0; + + return ( + <div className='modal-root__modal list-editor'> + <EditListForm /> + + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner list-editor__accounts'> + {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)} + </div> + + {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />} + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)} + </div>) + } + </Motion> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js new file mode 100644 index 000000000..ef829b937 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_timeline/index.js @@ -0,0 +1,202 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { connectListStream } from 'flavours/glitch/actions/streaming'; +import { expandListTimeline } from 'flavours/glitch/actions/timelines'; +import { fetchList, deleteList, updateList } from 'flavours/glitch/actions/lists'; +import { openModal } from 'flavours/glitch/actions/modal'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, + deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, + all_replies: { id: 'lists.replies_policy.all_replies', defaultMessage: 'any followed user' }, + no_replies: { id: 'lists.replies_policy.no_replies', defaultMessage: 'no one' }, + list_replies: { id: 'lists.replies_policy.list_replies', defaultMessage: 'members of the list' }, +}); + +const mapStateToProps = (state, props) => ({ + list: state.getIn(['lists', props.params.id]), + hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class ListTimeline extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), + intl: PropTypes.object.isRequired, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('LIST', { id: this.props.params.id })); + this.context.router.history.push('/'); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + const { id } = this.props.params; + + dispatch(fetchList(id)); + dispatch(expandListTimeline(id)); + + this.disconnect = dispatch(connectListStream(id)); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { id } = this.props.params; + this.props.dispatch(expandListTimeline(id, { maxId })); + } + + handleEditClick = () => { + this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id })); + } + + handleDeleteClick = () => { + const { dispatch, columnId, intl } = this.props; + const { id } = this.props.params; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteList(id)); + + if (!!columnId) { + dispatch(removeColumn(columnId)); + } else { + this.context.router.history.push('/lists'); + } + }, + })); + } + + handleRepliesPolicyChange = ({ target }) => { + const { dispatch, list } = this.props; + const { id } = this.props.params; + this.props.dispatch(updateList(id, undefined, false, target.value)); + } + + render () { + const { hasUnread, columnId, multiColumn, list, intl } = this.props; + const { id } = this.props.params; + const pinned = !!columnId; + const title = list ? list.get('title') : id; + const replies_policy = list ? list.get('replies_policy') : undefined; + + if (typeof list === 'undefined') { + return ( + <Column> + <div className='scrollable'> + <LoadingIndicator /> + </div> + </Column> + ); + } else if (list === false) { + return ( + <Column> + <div className='scrollable'> + <MissingIndicator /> + </div> + </Column> + ); + } + + return ( + <Column ref={this.setRef} label={title}> + <ColumnHeader + icon='list-ul' + active={hasUnread} + title={title} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <div className='column-header__links'> + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}> + <i className='fa fa-pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> + </button> + + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}> + <i className='fa fa-trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> + </button> + </div> + + { replies_policy !== undefined && ( + <div> + <div className='column-settings__row'> + <fieldset> + <legend><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></legend> + { ['no_replies', 'list_replies', 'all_replies'].map(policy => ( + <div className='setting-radio'> + <input className='setting-radio__input' id={['setting', 'radio', id, policy].join('-')} type='radio' value={policy} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> + <label className='setting-radio__label' htmlFor={['setting', 'radio', id, policy].join('-')}> + <FormattedMessage {...messages[policy]} /> + </label> + </div> + ))} + </fieldset> + </div> + </div> + )} + + <hr /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`list_timeline-${columnId}`} + timelineId={`list:${id}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js new file mode 100644 index 000000000..61fcbeaf9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, + title: { id: 'lists.new.create', defaultMessage: 'Add list' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'title']), + disabled: state.getIn(['listEditor', 'isSubmitting']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeListEditorTitle(value)), + onSubmit: () => dispatch(submitListEditor(true)), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class NewListForm extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + } + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + } + + handleClick = () => { + this.props.onSubmit(); + } + + render () { + const { value, disabled, intl } = this.props; + + const label = intl.formatMessage(messages.label); + const title = intl.formatMessage(messages.title); + + return ( + <form className='column-inline-form' onSubmit={this.handleSubmit}> + <label> + <span style={{ display: 'none' }}>{label}</span> + + <input + className='setting-text' + value={value} + disabled={disabled} + onChange={this.handleChange} + placeholder={label} + /> + </label> + + <IconButton + disabled={disabled} + icon='plus' + title={title} + onClick={this.handleClick} + /> + </form> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js new file mode 100644 index 000000000..8b0470c92 --- /dev/null +++ b/app/javascript/flavours/glitch/features/lists/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import { fetchLists } from 'flavours/glitch/actions/lists'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ColumnLink from 'flavours/glitch/features/ui/components/column_link'; +import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading'; +import NewListForm from './components/new_list_form'; +import { createSelector } from 'reselect'; + +const messages = defineMessages({ + heading: { id: 'column.lists', defaultMessage: 'Lists' }, + subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, +}); + +const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + lists: getOrderedLists(state), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Lists extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchLists()); + } + + render () { + const { intl, lists } = this.props; + + if (!lists) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='bars' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <NewListForm /> + + <div className='scrollable'> + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + + {lists.map(list => + <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> + )} + </div> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/index.js b/app/javascript/flavours/glitch/features/local_settings/index.js new file mode 100644 index 000000000..4e4605ea9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/index.js @@ -0,0 +1,65 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +// Our imports +import LocalSettingsPage from './page'; +import LocalSettingsNavigation from './navigation'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + onChange (setting, value) { + dispatch(changeLocalSetting(setting, value)); + }, + onClose () { + dispatch(closeModal()); + }, +}); + +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> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings); diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js new file mode 100644 index 000000000..c583c4863 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js @@ -0,0 +1,92 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Our imports +import LocalSettingsNavigationItem from './item'; +import { preferencesLink } from 'flavours/glitch/util/backend_links'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + general: { id: 'settings.general', defaultMessage: 'General' }, + compose: { id: 'settings.compose_box_opts', defaultMessage: 'Compose box' }, + content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' }, + 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} + icon='cogs' + title={intl.formatMessage(messages.general)} + /> + <LocalSettingsNavigationItem + active={index === 1} + index={1} + onNavigate={onNavigate} + icon='pencil' + title={intl.formatMessage(messages.compose)} + /> + <LocalSettingsNavigationItem + active={index === 2} + index={2} + onNavigate={onNavigate} + textIcon='CW' + title={intl.formatMessage(messages.content_warnings)} + /> + <LocalSettingsNavigationItem + active={index === 3} + index={3} + onNavigate={onNavigate} + icon='angle-double-up' + title={intl.formatMessage(messages.collapsed)} + /> + <LocalSettingsNavigationItem + active={index === 4} + index={4} + onNavigate={onNavigate} + icon='image' + title={intl.formatMessage(messages.media)} + /> + <LocalSettingsNavigationItem + active={index === 5} + href={ preferencesLink } + index={5} + icon='sliders' + title={intl.formatMessage(messages.preferences)} + /> + <LocalSettingsNavigationItem + active={index === 6} + className='close' + index={6} + onNavigate={onClose} + icon='times' + title={intl.formatMessage(messages.close)} + /> + </nav> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js new file mode 100644 index 000000000..68a998b6c --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js @@ -0,0 +1,68 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +export default class LocalSettingsPage extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + className: PropTypes.string, + href: PropTypes.string, + icon: PropTypes.string, + textIcon: 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, + textIcon, + onNavigate, + title, + } = this.props; + + const finalClassName = classNames('glitch', 'local-settings__navigation__item', { + active, + }, className); + + const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null); + + if (href) return ( + <a + href={href} + className={finalClassName} + > + {iconElem} <span>{title}</span> + </a> + ); + else if (onNavigate) return ( + <a + onClick={handleClick} + role='button' + tabIndex='0' + className={finalClassName} + > + {iconElem} <span>{title}</span> + </a> + ); + else return null; + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js new file mode 100644 index 000000000..a13bffa3a --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -0,0 +1,360 @@ +// 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'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +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' }, + side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep secondary toot button to set privacy' }, + side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' }, + side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' }, + regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' }, +}); + +@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={['show_reply_count']} + id='mastodon-settings--reply-count' + onChange={onChange} + > + <FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['hicolor_privacy_icons']} + id='mastodon-settings--hicolor_privacy_icons' + onChange={onChange} + > + <FormattedMessage id='settings.hicolor_privacy_icons' defaultMessage='High color privacy icons' /> + <span className='hint'><FormattedMessage id='settings.hicolor_privacy_icons.hint' defaultMessage="Display privacy icons in bright and easily distinguishable colors" /></span> + </LocalSettingsPageItem> + <section> + <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'tab_badge']} + id='mastodon-settings--notifications-tab_badge' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.tab_badge' defaultMessage="Unread notifications badge" /> + <span className='hint'><FormattedMessage id='settings.notifications.tab_badge.hint' defaultMessage="Display a badge for unread notifications in the column icons when the notifications column isn't open" /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'favicon_badge']} + id='mastodon-settings--notifications-favicon_badge' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' /> + <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span> + </LocalSettingsPageItem> + </section> + <section> + <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2> + <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)' /> + <span className='hint'><FormattedMessage id='settings.wide_view_hint' defaultMessage='Stretches columns to better fill the available space.' /></span> + </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> + <LocalSettingsPageItem + settings={settings} + item={['swipe_to_change_columns']} + id='mastodon-settings--swipe_to_change_columns' + onChange={onChange} + > + <FormattedMessage id='settings.swipe_to_change_columns' defaultMessage='Allow swiping to change columns (Mobile only)' /> + </LocalSettingsPageItem> + </section> + </div> + ), + ({ intl, onChange, settings }) => ( + <div className='glitch local-settings__page compose_box_opts'> + <h1><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['always_show_spoilers_field']} + id='mastodon-settings--always_show_spoilers_field' + onChange={onChange} + > + <FormattedMessage id='settings.always_show_spoilers_field' defaultMessage='Always enable the Content Warning field' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['preselect_on_reply']} + id='mastodon-settings--preselect_on_reply' + onChange={onChange} + > + <FormattedMessage id='settings.preselect_on_reply' defaultMessage='Pre-select usernames on reply' /> + <span className='hint'><FormattedMessage id='settings.preselect_on_reply_hint' defaultMessage='When replying to a conversation with multiple participants, pre-select usernames past the first' /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['confirm_missing_media_description']} + id='mastodon-settings--confirm_missing_media_description' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_missing_media_description' defaultMessage='Show confirmation dialog before sending toots lacking media descriptions' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['confirm_before_clearing_draft']} + id='mastodon-settings--confirm_before_clearing_draft' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_before_clearing_draft' defaultMessage='Show confirmation dialog before overwriting the message being composed' /> + </LocalSettingsPageItem> + <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> + <LocalSettingsPageItem + settings={settings} + item={['side_arm_reply_mode']} + id='mastodon-settings--side_arm_reply_mode' + options={[ + { value: 'keep', message: intl.formatMessage(messages.side_arm_keep) }, + { value: 'copy', message: intl.formatMessage(messages.side_arm_copy) }, + { value: 'restrict', message: intl.formatMessage(messages.side_arm_restrict) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm_reply_mode' defaultMessage='When replying to a toot:' /> + </LocalSettingsPageItem> + </div> + ), + ({ intl, onChange, settings }) => ( + <div className='glitch local-settings__page content_warnings'> + <h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content warnings' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['content_warnings', 'auto_unfold']} + id='mastodon-settings--content_warnings-auto_unfold' + onChange={onChange} + > + <FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['content_warnings', 'filter']} + id='mastodon-settings--content_warnings-auto_unfold' + onChange={onChange} + dependsOn={[['content_warnings', 'auto_unfold']]} + placeholder={intl.formatMessage(messages.regexp)} + > + <FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' /> + </LocalSettingsPageItem> + </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> + <LocalSettingsPageItem + settings={settings} + item={['collapsed', 'show_action_bar']} + id='mastodon-settings--collapsed-show-action-bar' + onChange={onChange} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.show_action_bar' defaultMessage='Show action buttons in 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' /> + <span className='hint'><FormattedMessage id='settings.media_letterbox_hint' defaultMessage='Scale down and letterbox media to fill the image containers instead of stretching and cropping them' /></span> + </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> + <LocalSettingsPageItem + settings={settings} + item={['inline_preview_cards']} + id='mastodon-settings--inline-preview-cards' + onChange={onChange} + > + <FormattedMessage id='settings.inline_preview_cards' defaultMessage='Inline preview cards for external links' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'reveal_behind_cw']} + id='mastodon-settings--reveal-behind-cw' + onChange={onChange} + > + <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' /> + </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/flavours/glitch/features/local_settings/page/item/index.js b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js new file mode 100644 index 000000000..66b937365 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js @@ -0,0 +1,112 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +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, + hint: PropTypes.string, + })), + settings: ImmutablePropTypes.map.isRequired, + placeholder: PropTypes.string, + }; + + handleChange = e => { + const { target } = e; + const { item, onChange, options, placeholder } = this.props; + if (options && options.length > 0) onChange(item, target.value); + else if (placeholder) onChange(item, target.value); + else onChange(item, target.checked); + } + + render () { + const { handleChange } = this; + const { settings, item, id, options, children, dependsOn, dependsOnNot, placeholder } = 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) => { + let optionId = `${id}--${opt.value}`; + return ( + <label htmlFor={optionId}> + <input type='radio' + name={id} + id={optionId} + value={opt.value} + onBlur={handleChange} + onChange={handleChange} + checked={ currentValue === opt.value } + disabled={!enabled} + /> + {opt.message} + {opt.hint && <span class='hint'>{opt.hint}</span>} + </label> + ); + }); + return ( + <div class='glitch local-settings__page__item radio_buttons'> + <fieldset> + <legend>{children}</legend> + {optionElems} + </fieldset> + </div> + ); + } else if (placeholder) { + return ( + <div className='glitch local-settings__page__item string'> + <label htmlFor={id}> + <p>{children}</p> + <p> + <input + id={id} + type='text' + value={settings.getIn(item)} + placeholder={placeholder} + onChange={handleChange} + disabled={!enabled} + /> + </p> + </label> + </div> + ); + } else return ( + <div className='glitch local-settings__page__item boolean'> + <label htmlFor={id}> + <input + id={id} + type='checkbox' + checked={settings.getIn(item)} + onChange={handleChange} + disabled={!enabled} + /> + {children} + </label> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/mutes/index.js b/app/javascript/flavours/glitch/features/mutes/index.js new file mode 100644 index 000000000..bbcbea701 --- /dev/null +++ b/app/javascript/flavours/glitch/features/mutes/index.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll-4'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import { fetchMutes, expandMutes } from 'flavours/glitch/actions/mutes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'mutes', 'items']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Mutes extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchMutes()); + } + + handleScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandMutes()); + } + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='mutes' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable mutes' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js new file mode 100644 index 000000000..22a10753f --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default class ClearColumnButton extends React.Component { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><i className='fa fa-eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js new file mode 100644 index 000000000..e29bd61f5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -0,0 +1,108 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import ClearColumnButton from './clear_column_button'; +import SettingToggle from './setting_toggle'; + +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + onPushChange = (path, checked) => { + this.props.onChange(['push', ...path], checked); + } + + render () { + const { settings, pushSettings, onChange, onClear } = this.props; + + const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; + const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; + const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; + const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; + const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />; + + return ( + <div> + <div className='column-settings__row'> + <ClearColumnButton onClick={onClear} /> + </div> + + <div role='group' aria-labelledby='notifications-filter-bar'> + <span id='notifications-filter-bar' className='column-settings__section'> + <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> + </span> + <div className='column-settings__row'> + <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> + <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-follow'> + <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-favourite'> + <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-mention'> + <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-reblog'> + <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-poll'> + <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js new file mode 100644 index 000000000..3457b7598 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -0,0 +1,101 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const tooltips = defineMessages({ + mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, + favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, + polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, + follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, +}); + +export default @injectIntl +class FilterBar extends React.PureComponent { + + static propTypes = { + selectFilter: PropTypes.func.isRequired, + selectedFilter: PropTypes.string.isRequired, + advancedMode: PropTypes.bool.isRequired, + intl: PropTypes.object.isRequired, + }; + + onClick (notificationType) { + return () => this.props.selectFilter(notificationType); + } + + render () { + const { selectedFilter, advancedMode, intl } = this.props; + const renderedElement = !advancedMode ? ( + <div className='notification__filter-bar'> + <button + className={selectedFilter === 'all' ? 'active' : ''} + onClick={this.onClick('all')} + > + <FormattedMessage + id='notifications.filter.all' + defaultMessage='All' + /> + </button> + <button + className={selectedFilter === 'mention' ? 'active' : ''} + onClick={this.onClick('mention')} + > + <FormattedMessage + id='notifications.filter.mentions' + defaultMessage='Mentions' + /> + </button> + </div> + ) : ( + <div className='notification__filter-bar'> + <button + className={selectedFilter === 'all' ? 'active' : ''} + onClick={this.onClick('all')} + > + <FormattedMessage + id='notifications.filter.all' + defaultMessage='All' + /> + </button> + <button + className={selectedFilter === 'mention' ? 'active' : ''} + onClick={this.onClick('mention')} + title={intl.formatMessage(tooltips.mentions)} + > + <i className='fa fa-fw fa-at' /> + </button> + <button + className={selectedFilter === 'favourite' ? 'active' : ''} + onClick={this.onClick('favourite')} + title={intl.formatMessage(tooltips.favourites)} + > + <i className='fa fa-fw fa-star' /> + </button> + <button + className={selectedFilter === 'reblog' ? 'active' : ''} + onClick={this.onClick('reblog')} + title={intl.formatMessage(tooltips.boosts)} + > + <i className='fa fa-fw fa-retweet' /> + </button> + <button + className={selectedFilter === 'poll' ? 'active' : ''} + onClick={this.onClick('poll')} + title={intl.formatMessage(tooltips.polls)} + > + <i className='fa fa-fw fa-tasks' /> + </button> + <button + className={selectedFilter === 'follow' ? 'active' : ''} + onClick={this.onClick('follow')} + title={intl.formatMessage(tooltips.follows)} + > + <i className='fa fa-fw fa-user-plus' /> + </button> + </div> + ); + return renderedElement; + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js new file mode 100644 index 000000000..ea81d9ab4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -0,0 +1,98 @@ +// 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'; +import { HotKeys } from 'react-hotkeys'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + this.handleOpenProfile(); + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, hidden } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + // Renders. + return ( + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-follow focusable' tabIndex='0'> + <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 hidden={hidden} id={account.get('id')} withNote={false} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js new file mode 100644 index 000000000..5c5bbf604 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -0,0 +1,137 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Our imports, +import StatusContainer from 'flavours/glitch/containers/status_container'; +import NotificationFollow from './follow'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + onUnmount: PropTypes.func, + }; + + render () { + const { + hidden, + notification, + onMoveDown, + onMoveUp, + onMention, + getScrollPosition, + updateScrollBottom, + } = this.props; + + switch(notification.get('type')) { + case 'follow': + return ( + <NotificationFollow + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + /> + ); + case 'mention': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + case 'favourite': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='favourite' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + case 'reblog': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='reblog' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + case 'poll': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='poll' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + default: + return null; + } + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/overlay.js b/app/javascript/flavours/glitch/features/notifications/components/overlay.js new file mode 100644 index 000000000..e56f9c628 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js @@ -0,0 +1,57 @@ +/** + * 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'; + +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/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js new file mode 100644 index 000000000..ac2211e48 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class SettingToggle extends React.PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + meta: PropTypes.node, + onChange: PropTypes.func.isRequired, + } + + onChange = ({ target }) => { + this.props.onChange(this.props.settingPath, target.checked); + } + + render () { + const { prefix, settings, settingPath, label, meta } = this.props; + const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); + + return ( + <div className='setting-toggle'> + <Toggle id={id} checked={settings.getIn(settingPath)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <label htmlFor={id} className='setting-toggle__label'>{label}</label> + {meta && <span className='setting-meta__label'>{meta}</span>} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js new file mode 100644 index 000000000..4b863712a --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js @@ -0,0 +1,43 @@ +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { setFilter } from 'flavours/glitch/actions/notifications'; +import { clearNotifications } from 'flavours/glitch/actions/notifications'; +import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, + clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, +}); + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (path, checked) { + if (path[0] === 'push') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else if (path[0] === 'quickFilter') { + dispatch(changeSetting(['notifications', ...path], checked)); + dispatch(setFilter('all')); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } + }, + + onClear () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()), + })); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js new file mode 100644 index 000000000..4d495c290 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import FilterBar from '../components/filter_bar'; +import { setFilter } from '../../../actions/notifications'; + +const makeMapStateToProps = state => ({ + selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']), +}); + +const mapDispatchToProps = (dispatch) => ({ + selectFilter (newActiveFilter) { + dispatch(setFilter(newActiveFilter)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..be007f30b --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js @@ -0,0 +1,26 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { makeGetNotification } from 'flavours/glitch/selectors'; +import Notification from '../components/notification'; +import { mentionCompose } from 'flavours/glitch/actions/compose'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + notifCleaning: state.getIn(['notifications', 'cleaningMode']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + onMention: (account, router) => { + dispatch(mentionCompose(account, router)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js new file mode 100644 index 000000000..ee2d19814 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js @@ -0,0 +1,18 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import NotificationOverlay from '../components/overlay'; +import { markNotificationForDelete } from 'flavours/glitch/actions/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/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js new file mode 100644 index 000000000..f2a1ccc3b --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -0,0 +1,254 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + enterNotificationClearingMode, + expandNotifications, + scrollTopNotifications, + mountNotifications, + unmountNotifications, +} from 'flavours/glitch/actions/notifications'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import NotificationContainer from './containers/notification_container'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import FilterBarContainer from './containers/filter_bar_container'; +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; +import { debounce } from 'lodash'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import LoadGap from 'flavours/glitch/components/load_gap'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, +}); + +const getNotifications = createSelector([ + state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), + state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + state => state.getIn(['notifications', 'items']), +], (showFilterBar, allowedType, excludedTypes, notifications) => { + if (!showFilterBar || allowedType === 'all') { + // used if user changed the notification settings after loading the notifications from the server + // otherwise a list of notifications will come pre-filtered from the backend + // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category + return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); + } + return notifications.filter(item => item !== null && allowedType === item.get('type')); +}); + +const mapStateToProps = state => ({ + showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']), + notifications: getNotifications(state), + localSettings: state.get('local_settings'), + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0, + hasMore: state.getIn(['notifications', 'hasMore']), + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), +}); + +/* glitch */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + onMount() { + dispatch(mountNotifications()); + }, + onUnmount() { + dispatch(unmountNotifications()); + }, + dispatch, +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class Notifications extends React.PureComponent { + + static propTypes = { + columnId: PropTypes.string, + notifications: ImmutablePropTypes.list.isRequired, + showFilterBar: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + intl: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + }; + + static defaultProps = { + trackScroll: true, + }; + + handleLoadGap = (maxId) => { + this.props.dispatch(expandNotifications({ maxId })); + }; + + handleLoadOlder = debounce(() => { + const last = this.props.notifications.last(); + this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); + }, 300, { leading: true }); + + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); + }, 100); + + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('NOTIFICATIONS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setColumnRef = c => { + this.column = c; + } + + handleMoveUp = id => { + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; + this._selectChild(elementIndex, true); + } + + handleMoveDown = id => { + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; + this._selectChild(elementIndex, false); + } + + _selectChild (index, align_top) { + const container = this.column.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } + element.focus(); + } + } + + componentDidMount () { + const { onMount } = this.props; + if (onMount) { + onMount(); + } + } + + componentWillUnmount () { + const { onUnmount } = this.props; + if (onUnmount) { + onUnmount(); + } + } + + render () { + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props; + const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; + + let scrollableContent = null; + + const filterBarContainer = showFilterBar + ? (<FilterBarContainer />) + : null; + + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; + } else if (notifications.size > 0 || hasMore) { + scrollableContent = notifications.map((item, index) => item === null ? ( + <LoadGap + key={'gap:' + notifications.getIn([index + 1, 'id'])} + disabled={isLoading} + maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} + onClick={this.handleLoadGap} + /> + ) : ( + <NotificationContainer + key={item.get('id')} + notification={item} + accountId={item.get('account')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); + } else { + scrollableContent = null; + } + + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + showLoading={isLoading && notifications.size === 0} + hasMore={hasMore} + emptyMessage={emptyMessage} + onLoadMore={this.handleLoadOlder} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + shouldUpdateScroll={shouldUpdateScroll} + > + {scrollableContent} + </ScrollableList> + ); + + return ( + <Column + ref={this.setColumnRef} + name='notifications' + extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null} + label={intl.formatMessage(messages.title)} + > + <ColumnHeader + icon='bell' + active={isUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + 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> + {filterBarContainer} + {scrollContainer} + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js new file mode 100644 index 000000000..149d05c32 --- /dev/null +++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import { injectIntl } from 'react-intl'; +import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts'; +import Account from 'flavours/glitch/features/list_editor/components/account'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(unpinAccount(accountId)), + onAdd: () => dispatch(pinAccount(accountId)), +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js new file mode 100644 index 000000000..5a1efce0a --- /dev/null +++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl } from 'react-intl'; +import { + fetchPinnedAccountsSuggestions, + clearPinnedAccountsSuggestions, + changePinnedAccountsSuggestions +} from '../../../actions/accounts'; +import Search from 'flavours/glitch/features/list_editor/components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)), + onClear: () => dispatch(clearPinnedAccountsSuggestions()), + onChange: value => dispatch(changePinnedAccountsSuggestions(value)), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search)); diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js new file mode 100644 index 000000000..7484e458e --- /dev/null +++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts'; +import AccountContainer from './containers/account_container'; +import SearchContainer from './containers/search_container'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +const mapStateToProps = state => ({ + accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']), + searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: () => dispatch(fetchPinnedAccounts()), + onClear: () => dispatch(clearPinnedAccountsSuggestions()), + onReset: () => dispatch(resetPinnedAccountsEditor()), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class PinnedAccountsEditor extends ImmutablePureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + accountIds: ImmutablePropTypes.list.isRequired, + searchAccountIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize } = this.props; + onInitialize(); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountIds, searchAccountIds, onClear } = this.props; + const showSearch = searchAccountIds.size > 0; + + return ( + <div className='modal-root__modal list-editor'> + <h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4> + + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner list-editor__accounts'> + {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)} + </div> + + {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />} + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)} + </div>) + } + </Motion> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.js b/app/javascript/flavours/glitch/features/pinned_statuses/index.js new file mode 100644 index 000000000..f56d70176 --- /dev/null +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchPinnedStatuses } from 'flavours/glitch/actions/pin_statuses'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import StatusList from 'flavours/glitch/components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.pins', defaultMessage: 'Pinned toot' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'pins', 'items']), + hasMore: !!state.getIn(['status_lists', 'pins', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class PinnedStatuses extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + hasMore: PropTypes.bool.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchPinnedStatuses()); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, statusIds, hasMore } = this.props; + + return ( + <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> + <ColumnBackButtonSlim /> + <StatusList + statusIds={statusIds} + scrollKey='pinned_statuses' + hasMore={hasMore} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..ec4d74737 --- /dev/null +++ b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import ColumnSettings from 'flavours/glitch/features/community_timeline/components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { changeColumnParams } from 'flavours/glitch/actions/columns'; + +const mapStateToProps = (state, { columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + + return { + settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'public']), + }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => { + return { + onChange (key, checked) { + if (columnId) { + dispatch(changeColumnParams(columnId, key, checked)); + } else { + dispatch(changeSetting(['public', ...key], checked)); + } + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js new file mode 100644 index 000000000..7fe472202 --- /dev/null +++ b/app/javascript/flavours/glitch/features/public_timeline/index.js @@ -0,0 +1,136 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { expandPublicTimeline } from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectPublicStream } from 'flavours/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.public', defaultMessage: 'Federated timeline' }, +}); + +const mapStateToProps = (state, { onlyMedia, columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + + return { + hasUnread: state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`, 'unread']) > 0, + onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']), + }; +}; + +@connect(mapStateToProps) +@injectIntl +export default class PublicTimeline extends React.PureComponent { + + static defaultProps = { + onlyMedia: false, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasUnread: PropTypes.bool, + onlyMedia: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch, onlyMedia } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('PUBLIC', { other: { onlyMedia } })); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch, onlyMedia } = this.props; + + dispatch(expandPublicTimeline({ onlyMedia })); + this.disconnect = dispatch(connectPublicStream({ onlyMedia })); + } + + componentDidUpdate (prevProps) { + if (prevProps.onlyMedia !== this.props.onlyMedia) { + const { dispatch, onlyMedia } = this.props; + + this.disconnect(); + dispatch(expandPublicTimeline({ onlyMedia })); + this.disconnect = dispatch(connectPublicStream({ onlyMedia })); + } + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { dispatch, onlyMedia } = this.props; + + dispatch(expandPublicTimeline({ maxId, onlyMedia })); + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen) + } + + render () { + const { intl, columnId, hasUnread, multiColumn, onlyMedia } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='federated' label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='globe' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer columnId={columnId} /> + </ColumnHeader> + + <StatusListContainer + timelineId={`public${onlyMedia ? ':media' : ''}`} + onLoadMore={this.handleLoadMore} + trackScroll={!pinned} + scrollKey={`public_timeline-${columnId}`} + emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/reblogs/index.js b/app/javascript/flavours/glitch/features/reblogs/index.js new file mode 100644 index 000000000..e007506b7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/reblogs/index.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { fetchReblogs } from 'flavours/glitch/actions/interactions'; +import { ScrollContainer } from 'react-router-scroll-4'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' }, +}); + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Reblogs extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchReblogs(nextProps.params.statusId)); + } + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='retweet' + title={intl.formatMessage(messages.heading)} + onClick={this.handleHeaderClick} + showBackButton + /> + + <ScrollContainer scrollKey='reblogs' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable reblogs'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.js b/app/javascript/flavours/glitch/features/report/components/status_check_box.js new file mode 100644 index 000000000..cc49042fc --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; +import noop from 'lodash/noop'; +import StatusContent from 'flavours/glitch/components/status_content'; +import { MediaGallery, Video } from 'flavours/glitch/util/async-components'; +import Bundle from 'flavours/glitch/features/ui/components/bundle'; + +export default class StatusCheckBox extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + checked: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + disabled: PropTypes.bool, + }; + + render () { + const { status, checked, onToggle, disabled } = this.props; + let media = null; + + if (status.get('reblog')) { + return null; + } + + if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const video = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => ( + <Component + preview={video.get('preview_url')} + blurhash={video.get('blurhash')} + src={video.get('url')} + alt={video.get('description')} + width={239} + height={110} + inline + sensitive={status.get('sensitive')} + revealed={false} + onOpenVideo={noop} + /> + )} + </Bundle> + ); + } else { + media = ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > + {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} revealed={false} height={110} onOpenMedia={noop} />} + </Bundle> + ); + } + } + + return ( + <div className='status-check-box'> + <div className='status-check-box__status'> + <StatusContent + status={status} + media={media} + /> + </div> + + <div className='status-check-box-toggle'> + <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js new file mode 100644 index 000000000..9bfd41ffc --- /dev/null +++ b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import StatusCheckBox from '../components/status_check_box'; +import { toggleStatusReport } from 'flavours/glitch/actions/reports'; +import { Set as ImmutableSet } from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onToggle (e) { + dispatch(toggleStatusReport(id, e.target.checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js new file mode 100644 index 000000000..b33c21cb5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js @@ -0,0 +1,20 @@ +import React from 'react'; +import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; +import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container'; +import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container'; +import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container'; + +export default class Compose extends React.PureComponent { + + render () { + return ( + <div> + <ComposeFormContainer /> + <NotificationsContainer /> + <ModalContainer /> + <LoadingBarContainer className='loading-bar' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js new file mode 100644 index 000000000..4fbd504ef --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines'; +import Masonry from 'react-masonry-infinite'; +import { List as ImmutableList } from 'immutable'; +import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container'; +import { debounce } from 'lodash'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; + +const mapStateToProps = (state, { hashtag }) => ({ + statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()), + isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false), + hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false), +}); + +export default @connect(mapStateToProps) +class HashtagTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool.isRequired, + hasMore: PropTypes.bool.isRequired, + hashtag: PropTypes.string.isRequired, + }; + + componentDidMount () { + const { dispatch, hashtag } = this.props; + + dispatch(expandHashtagTimeline(hashtag)); + } + + handleLoadMore = () => { + const maxId = this.props.statusIds.last(); + + if (maxId) { + this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId })); + } + } + + setRef = c => { + this.masonry = c; + } + + handleHeightChange = debounce(() => { + if (!this.masonry) { + return; + } + + this.masonry.forcePack(); + }, 50) + + render () { + const { statusIds, hasMore, isLoading } = this.props; + + const sizes = [ + { columns: 1, gutter: 0 }, + { mq: '415px', columns: 1, gutter: 10 }, + { mq: '640px', columns: 2, gutter: 10 }, + { mq: '960px', columns: 3, gutter: 10 }, + { mq: '1255px', columns: 3, gutter: 10 }, + ]; + + const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined; + + return ( + <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}> + {statusIds.map(statusId => ( + <div className='statuses-grid__item' key={statusId}> + <DetailedStatusContainer + id={statusId} + compact + measureHeight + onHeightChange={this.handleHeightChange} + /> + </div> + )).toArray()} + </Masonry> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js new file mode 100644 index 000000000..5f8a369ff --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; +import Masonry from 'react-masonry-infinite'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container'; +import { debounce } from 'lodash'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; + +const mapStateToProps = (state, { local }) => { + const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap()); + + return { + statusIds: timeline.get('items', ImmutableList()), + isLoading: timeline.get('isLoading', false), + hasMore: timeline.get('hasMore', false), + }; +}; + +export default @connect(mapStateToProps) +class PublicTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool.isRequired, + hasMore: PropTypes.bool.isRequired, + local: PropTypes.bool, + }; + + componentDidMount () { + this._connect(); + } + + componentDidUpdate (prevProps) { + if (prevProps.local !== this.props.local) { + this._disconnect(); + this._connect(); + } + } + + _connect () { + const { dispatch, local } = this.props; + + dispatch(local ? expandCommunityTimeline() : expandPublicTimeline()); + } + + handleLoadMore = () => { + const { dispatch, statusIds, local } = this.props; + const maxId = statusIds.last(); + + if (maxId) { + dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId })); + } + } + + setRef = c => { + this.masonry = c; + } + + handleHeightChange = debounce(() => { + if (!this.masonry) { + return; + } + + this.masonry.forcePack(); + }, 50) + + render () { + const { statusIds, hasMore, isLoading } = this.props; + + const sizes = [ + { columns: 1, gutter: 0 }, + { mq: '415px', columns: 1, gutter: 10 }, + { mq: '640px', columns: 2, gutter: 10 }, + { mq: '960px', columns: 3, gutter: 10 }, + { mq: '1255px', columns: 3, gutter: 10 }, + ]; + + const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined; + + return ( + <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}> + {statusIds.map(statusId => ( + <div className='statuses-grid__item' key={statusId}> + <DetailedStatusContainer + id={statusId} + compact + measureHeight + onHeightChange={this.handleHeightChange} + /> + </div> + )).toArray()} + </Masonry> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js new file mode 100644 index 000000000..8291319c3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -0,0 +1,216 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import { me, isStaff } from 'flavours/glitch/util/initial_state'; +import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + block: { id: 'status.block', defaultMessage: 'Block @{name}' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, + copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, +}); + +@injectIntl +export default class ActionBar extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func.isRequired, + onReblog: PropTypes.func.isRequired, + onFavourite: PropTypes.func.isRequired, + onBookmark: PropTypes.func.isRequired, + onMute: PropTypes.func, + onMuteConversation: PropTypes.func, + onBlock: PropTypes.func, + onDelete: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + handleReplyClick = () => { + this.props.onReply(this.props.status); + } + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + } + + handleFavouriteClick = (e) => { + this.props.onFavourite(this.props.status, e); + } + + handleBookmarkClick = (e) => { + this.props.onBookmark(this.props.status, e); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status, this.context.router.history); + } + + handleRedraftClick = () => { + this.props.onDelete(this.props.status, this.context.router.history, true); + } + + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.context.router.history); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + handleBlockClick = () => { + this.props.onBlock(this.props.status); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleShare = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + handleCopy = () => { + const url = this.props.status.get('url'); + const textarea = document.createElement('textarea'); + + textarea.textContent = url; + textarea.style.position = 'fixed'; + + document.body.appendChild(textarea); + + try { + textarea.select(); + document.execCommand('copy'); + } catch (e) { + + } finally { + document.body.removeChild(textarea); + } + } + + render () { + const { status, intl } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const mutingConversation = status.get('muted'); + + let menu = []; + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy }); + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + menu.push(null); + } + + if (me === status.getIn(['account', 'id'])) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + + menu.push(null); + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + 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 (isStaff && (accountAdminLink || statusAdminLink)) { + menu.push(null); + if (accountAdminLink !== undefined) { + menu.push({ + text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), + href: accountAdminLink(status.getIn(['account', 'id'])), + }); + } + if (statusAdminLink !== undefined) { + menu.push({ + text: intl.formatMessage(messages.admin_status), + href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')), + }); + } + } + } + + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div> + ); + + let reblogIcon = 'retweet'; + //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' && me !== status.getIn(['account', 'id']))); + let reblog_message = status.get('visibility') === 'private' ? messages.reblog_private : messages.reblog; + + return ( + <div className='detailed-status__action-bar'> + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div> + <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblog_message)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> + <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div> + {shareButton} + <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> + + <div className='detailed-status__action-bar-dropdown'> + <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js new file mode 100644 index 000000000..f974a87a1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -0,0 +1,217 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Immutable from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import punycode from 'punycode'; +import classnames from 'classnames'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const trim = (text, len) => { + const cut = text.indexOf(' ', len); + + if (cut === -1) { + return text; + } + + return text.substring(0, cut) + (text.length > len ? '…' : ''); +}; + +const domParser = new DOMParser(); + +const addAutoPlay = html => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + + if (iframe) { + if (iframe.src.indexOf('?') !== -1) { + iframe.src += '&'; + } else { + iframe.src += '?'; + } + + iframe.src += 'autoplay=1&auto_play=1'; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return document.querySelector('body').innerHTML; + } + + return html; +}; + +export default class Card extends React.PureComponent { + + static propTypes = { + card: ImmutablePropTypes.map, + maxDescription: PropTypes.number, + onOpenMedia: PropTypes.func.isRequired, + compact: PropTypes.bool, + defaultWidth: PropTypes.number, + cacheWidth: PropTypes.func, + }; + + static defaultProps = { + maxDescription: 50, + compact: false, + }; + + state = { + width: this.props.defaultWidth || 280, + embedded: false, + }; + + componentWillReceiveProps (nextProps) { + if (!Immutable.is(this.props.card, nextProps.card)) { + this.setState({ embedded: false }); + } + } + + handlePhotoClick = () => { + const { card, onOpenMedia } = this.props; + + onOpenMedia( + Immutable.fromJS([ + { + type: 'image', + url: card.get('embed_url'), + description: card.get('title'), + meta: { + original: { + width: card.get('width'), + height: card.get('height'), + }, + }, + }, + ]), + 0 + ); + }; + + handleEmbedClick = () => { + const { card } = this.props; + + if (card.get('type') === 'photo') { + this.handlePhotoClick(); + } else { + this.setState({ embedded: true }); + } + } + + setRef = c => { + if (c) { + if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth); + this.setState({ width: c.offsetWidth }); + } + } + + renderVideo () { + const { card } = this.props; + const content = { __html: addAutoPlay(card.get('html')) }; + const { width } = this.state; + const ratio = card.get('width') / card.get('height'); + const height = width / ratio; + + return ( + <div + ref={this.setRef} + className='status-card__image status-card-video' + dangerouslySetInnerHTML={content} + style={{ height }} + /> + ); + } + + render () { + const { card, maxDescription, compact, defaultWidth } = this.props; + const { width, embedded } = this.state; + + if (card === null) { + return null; + } + + const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); + const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded; + const interactive = card.get('type') !== 'link'; + const className = classnames('status-card', { horizontal, compact, interactive }); + const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>; + const ratio = card.get('width') / card.get('height'); + const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); + + const description = ( + <div className='status-card__content'> + {title} + {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} + <span className='status-card__host'>{provider}</span> + </div> + ); + + let embed = ''; + let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; + + if (interactive) { + if (embedded) { + embed = this.renderVideo(); + } else { + let iconVariant = 'play'; + + if (card.get('type') === 'photo') { + iconVariant = 'search-plus'; + } + + embed = ( + <div className='status-card__image'> + {thumbnail} + + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>} + </div> + </div> + </div> + ); + } + + return ( + <div className={className} ref={this.setRef}> + {embed} + {!compact && description} + </div> + ); + } else if (card.get('image')) { + embed = ( + <div className='status-card__image'> + {thumbnail} + </div> + ); + } else { + embed = ( + <div className='status-card__image'> + <i className='fa fa-file-text' /> + </div> + ); + } + + return ( + <a href={card.get('url')} className={className} target='_blank' rel='noopener' ref={this.setRef}> + {embed} + {description} + </a> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js new file mode 100644 index 000000000..03d98fde8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -0,0 +1,253 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import StatusContent from 'flavours/glitch/components/status_content'; +import MediaGallery from 'flavours/glitch/components/media_gallery'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import { Link } from 'react-router-dom'; +import { FormattedDate, FormattedNumber } from 'react-intl'; +import Card from './card'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Video from 'flavours/glitch/features/video'; +import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; +import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; +import classNames from 'classnames'; +import PollContainer from 'flavours/glitch/containers/poll_container'; + +export default class DetailedStatus extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map.isRequired, + onOpenMedia: PropTypes.func.isRequired, + onOpenVideo: PropTypes.func.isRequired, + onToggleHidden: PropTypes.func, + expanded: PropTypes.bool, + measureHeight: PropTypes.bool, + onHeightChange: PropTypes.func, + domain: PropTypes.string.isRequired, + compact: PropTypes.bool, + }; + + state = { + height: null, + }; + + handleAccountClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) { + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state); + } + + e.stopPropagation(); + } + + parseClick = (e, destination) => { + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) { + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(destination, state); + } + + e.stopPropagation(); + } + + handleOpenVideo = (media, startTime) => { + this.props.onOpenVideo(media, startTime); + } + + _measureHeight (heightJustChanged) { + if (this.props.measureHeight && this.node) { + scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 })); + + if (this.props.onHeightChange && heightJustChanged) { + this.props.onHeightChange(); + } + } + } + + setRef = c => { + this.node = c; + this._measureHeight(); + } + + componentDidUpdate (prevProps, prevState) { + this._measureHeight(prevState.height !== this.state.height); + } + + handleChildUpdate = () => { + this._measureHeight(); + } + + handleModalLink = e => { + e.preventDefault(); + + let href; + + if (e.target.nodeName !== 'A') { + href = e.target.parentNode.href; + } else { + href = e.target.href; + } + + window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); + } + + render () { + const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; + const { expanded, onToggleHidden, settings } = this.props; + const outerStyle = { boxSizing: 'border-box' }; + const { compact } = this.props; + + if (!status) { + return null; + } + + let media = null; + let mediaIcon = null; + let applicationLink = ''; + let reblogLink = ''; + let reblogIcon = 'retweet'; + let favouriteLink = ''; + + if (this.props.measureHeight) { + outerStyle.height = `${this.state.height}px`; + } + + if (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + mediaIcon = 'tasks'; + } else if (status.get('media_attachments').size > 0) { + 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')} + blurhash={video.get('blurhash')} + src={video.get('url')} + alt={video.get('description')} + inline + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + preventPlayback={!expanded} + onOpenVideo={this.handleOpenVideo} + autoplay + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + /> + ); + mediaIcon = 'video-camera'; + } else { + media = ( + <MediaGallery + standalone + sensitive={status.get('sensitive')} + media={status.get('media_attachments')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + hidden={!expanded} + onOpenMedia={this.props.onOpenMedia} + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + /> + ); + mediaIcon = 'picture-o'; + } + } else if (status.get('card')) { + media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />; + mediaIcon = 'link'; + } + + 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>; + } + + if (status.get('visibility') === 'direct') { + reblogIcon = 'envelope'; + } else if (status.get('visibility') === 'private') { + reblogIcon = 'lock'; + } + + if (status.get('visibility') === 'private') { + reblogLink = <i className={`fa fa-${reblogIcon}`} />; + } else if (this.context.router) { + reblogLink = ( + <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> + <i className={`fa fa-${reblogIcon}`} /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </Link> + ); + } else { + reblogLink = ( + <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> + <i className={`fa fa-${reblogIcon}`} /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </a> + ); + } + + if (this.context.router) { + favouriteLink = ( + <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> + </Link> + ); + } else { + favouriteLink = ( + <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> + </a> + ); + } + + return ( + <div style={outerStyle}> + <div ref={this.setRef} className={classNames('detailed-status', { compact })} data-status-by={status.getIn(['account', 'acct'])}> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> + <DisplayName account={status.get('account')} localDomain={this.props.domain} /> + </a> + + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + expanded={expanded} + collapsed={false} + onExpandedToggle={onToggleHidden} + parseClick={this.parseClick} + onUpdate={this.handleChildUpdate} + disabled + /> + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{applicationLink} · {reblogLink} · {favouriteLink} · <VisibilityIcon visibility={status.get('visibility')} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js new file mode 100644 index 000000000..e6c390537 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js @@ -0,0 +1,172 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import DetailedStatus from '../components/detailed_status'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { + replyCompose, + mentionCompose, + directCompose, +} from 'flavours/glitch/actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite, + pin, + unpin, +} from 'flavours/glitch/actions/interactions'; +import { blockAccount } from 'flavours/glitch/actions/accounts'; +import { + muteStatus, + unmuteStatus, + deleteStatus, + hideStatus, + revealStatus, +} from 'flavours/glitch/actions/statuses'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state'; +import { showAlertForError } from 'flavours/glitch/actions/alerts'; + +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?' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props), + domain: state.getIn(['meta', 'domain']), + settings: state.get('local_settings'), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch((_, getState) => { + let state = getState(); + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status, router)), + })); + } else { + dispatch(replyCompose(status, router)); + } + }); + }, + + onModalReblog (status) { + dispatch(reblog(status)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !boostModal) { + 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'), + onError: error => dispatch(showAlertForError(error)), + })); + }, + + onDelete (status, history, withRedraft = false) { + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'), history, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), + })); + } + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (status) { + const account = status.get('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'))), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account, status)); + }, + })); + }, + + 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)(DetailedStatus)); diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js new file mode 100644 index 000000000..57d70db1a --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -0,0 +1,536 @@ +import Immutable from 'immutable'; +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchStatus } from 'flavours/glitch/actions/statuses'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import DetailedStatus from './components/detailed_status'; +import ActionBar from './components/action_bar'; +import Column from 'flavours/glitch/features/ui/components/column'; +import { + favourite, + unfavourite, + bookmark, + unbookmark, + reblog, + unreblog, + pin, + unpin, +} from 'flavours/glitch/actions/interactions'; +import { + replyCompose, + mentionCompose, + directCompose, +} from 'flavours/glitch/actions/compose'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; +import { blockAccount } from 'flavours/glitch/actions/accounts'; +import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { ScrollContainer } from 'react-router-scroll-4'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import ColumnHeader from '../../components/column_header'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; +import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; +import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; +import { textForScreenReader } from 'flavours/glitch/components/status'; + +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?' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, + hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' }, + detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + tootHeading: { id: 'column.toot', defaultMessage: 'Toots and replies' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => { + const status = getStatus(state, { id: props.params.statusId }); + let ancestorsIds = Immutable.List(); + let descendantsIds = Immutable.List(); + + if (status) { + ancestorsIds = ancestorsIds.withMutations(mutable => { + let id = status.get('in_reply_to_id'); + + while (id) { + mutable.unshift(id); + id = state.getIn(['contexts', 'inReplyTos', id]); + } + }); + + descendantsIds = descendantsIds.withMutations(mutable => { + const ids = [status.get('id')]; + + while (ids.length > 0) { + let id = ids.shift(); + const replies = state.getIn(['contexts', 'replies', id]); + + if (status.get('id') !== id) { + mutable.push(id); + } + + if (replies) { + replies.reverse().forEach(reply => { + ids.unshift(reply); + }); + } + } + }); + } + + return { + status, + ancestorsIds, + descendantsIds, + settings: state.get('local_settings'), + askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, + domain: state.getIn(['meta', 'domain']), + }; + }; + + return mapStateToProps; +}; + +@injectIntl +@connect(makeMapStateToProps) +export default class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + 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, + askReplyConfirmation: PropTypes.bool, + domain: PropTypes.string.isRequired, + }; + + state = { + fullscreen: false, + isExpanded: undefined, + threadExpanded: undefined, + statusId: undefined, + }; + + componentDidMount () { + attachFullscreenListener(this.onFullScreenChange); + this.props.dispatch(fetchStatus(this.props.params.statusId)); + + const { status, ancestorsIds } = this.props; + + if (status && ancestorsIds && ancestorsIds.size > 0) { + const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1]; + + window.requestAnimationFrame(() => { + element.scrollIntoView(true); + }); + } + } + + static getDerivedStateFromProps(props, state) { + if (state.statusId === props.params.statusId || !props.params.statusId) { + return null; + } + + props.dispatch(fetchStatus(props.params.statusId)); + + return { + threadExpanded: undefined, + isExpanded: autoUnfoldCW(props.settings, props.status), + statusId: props.params.statusId, + }; + } + + handleExpandedToggle = () => { + if (this.props.status.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + handleModalFavourite = (status) => { + this.props.dispatch(favourite(status)); + } + + handleFavouriteClick = (status, e) => { + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + if ((e && e.shiftKey) || !favouriteModal) { + this.handleModalFavourite(status); + } else { + this.props.dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite })); + } + } + } + + handlePin = (status) => { + if (status.get('pinned')) { + this.props.dispatch(unpin(status)); + } else { + this.props.dispatch(pin(status)); + } + } + + handleReplyClick = (status) => { + let { askReplyConfirmation, dispatch, intl } = this.props; + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), + onConfirm: () => dispatch(replyCompose(status, this.context.router.history)), + })); + } else { + dispatch(replyCompose(status, this.context.router.history)); + } + } + + handleModalReblog = (status) => { + this.props.dispatch(reblog(status)); + } + + handleReblogClick = (status, e) => { + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + if ((e && e.shiftKey) || !boostModal) { + this.handleModalReblog(status); + } else { + this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); + } + } + } + + handleBookmarkClick = (status) => { + if (status.get('bookmarked')) { + this.props.dispatch(unbookmark(status)); + } else { + this.props.dispatch(bookmark(status)); + } + } + + handleDeleteClick = (status, history, withRedraft = false) => { + const { dispatch, intl } = this.props; + + if (!deleteModal) { + dispatch(deleteStatus(status.get('id'), history, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), + })); + } + } + + handleDirectClick = (account, router) => { + this.props.dispatch(directCompose(account, router)); + } + + handleMentionClick = (account, router) => { + this.props.dispatch(mentionCompose(account, router)); + } + + handleOpenMedia = (media, index) => { + this.props.dispatch(openModal('MEDIA', { media, index })); + } + + handleOpenVideo = (media, time) => { + this.props.dispatch(openModal('VIDEO', { media, time })); + } + + handleMuteClick = (account) => { + this.props.dispatch(initMuteModal(account)); + } + + handleConversationMuteClick = (status) => { + if (status.get('muted')) { + this.props.dispatch(unmuteStatus(status.get('id'))); + } else { + this.props.dispatch(muteStatus(status.get('id'))); + } + } + + handleToggleAll = () => { + const { isExpanded } = this.state; + this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded }); + } + + handleBlockClick = (status) => { + const { dispatch, intl } = this.props; + const account = status.get('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'))), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account, status)); + }, + })); + } + + handleReport = (status) => { + this.props.dispatch(initReport(status.get('account'), status)); + } + + handleEmbed = (status) => { + this.props.dispatch(openModal('EMBED', { url: status.get('url') })); + } + + handleHotkeyMoveUp = () => { + this.handleMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.handleMoveDown(this.props.status.get('id')); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.handleReplyClick(this.props.status); + } + + handleHotkeyFavourite = () => { + this.handleFavouriteClick(this.props.status); + } + + handleHotkeyBoost = () => { + this.handleReblogClick(this.props.status); + } + + handleHotkeyBookmark = () => { + this.handleBookmarkClick(this.props.status); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.handleMentionClick(this.props.status); + } + + handleHotkeyOpenProfile = () => { + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state); + } + + handleMoveUp = id => { + const { status, ancestorsIds, descendantsIds } = this.props; + + if (id === status.get('id')) { + this._selectChild(ancestorsIds.size - 1, true); + } else { + let index = ancestorsIds.indexOf(id); + + if (index === -1) { + index = descendantsIds.indexOf(id); + this._selectChild(ancestorsIds.size + index, true); + } else { + this._selectChild(index - 1, true); + } + } + } + + handleMoveDown = id => { + const { status, ancestorsIds, descendantsIds } = this.props; + + if (id === status.get('id')) { + this._selectChild(ancestorsIds.size + 1, false); + } else { + let index = ancestorsIds.indexOf(id); + + if (index === -1) { + index = descendantsIds.indexOf(id); + this._selectChild(ancestorsIds.size + index + 2, false); + } else { + this._selectChild(index + 1, false); + } + } + } + + _selectChild (index, align_top) { + const container = this.node; + const element = container.querySelectorAll('.focusable')[index]; + + if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } + element.focus(); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + renderChildren (list) { + return list.map(id => ( + <StatusContainer + key={id} + id={id} + expanded={this.state.threadExpanded} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType='thread' + /> + )); + } + + setExpansion = value => { + this.setState({ isExpanded: value }); + } + + setRef = c => { + this.node = c; + } + + setColumnRef = c => { + this.column = c; + } + + componentDidUpdate (prevProps) { + if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) { + const { status, ancestorsIds } = this.props; + + if (status && ancestorsIds && ancestorsIds.size > 0) { + const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1]; + + window.requestAnimationFrame(() => { + element.scrollIntoView(true); + }); + } + } + } + + componentWillUnmount () { + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + shouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + render () { + let ancestors, descendants; + const { setExpansion } = this; + const { status, settings, ancestorsIds, descendantsIds, intl, domain } = this.props; + const { fullscreen, isExpanded } = this.state; + + if (status === null) { + return ( + <Column> + <ColumnBackButton /> + <MissingIndicator /> + </Column> + ); + } + + if (ancestorsIds && ancestorsIds.size > 0) { + ancestors = <div>{this.renderChildren(ancestorsIds)}</div>; + } + + if (descendantsIds && descendantsIds.size > 0) { + descendants = <div>{this.renderChildren(descendantsIds)}</div>; + } + + const handlers = { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + bookmark: this.handleHotkeyBookmark, + mention: this.handleHotkeyMention, + openProfile: this.handleHotkeyOpenProfile, + toggleSpoiler: this.handleExpandedToggle, + }; + + return ( + <Column ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}> + <ColumnHeader + icon='comment' + title={intl.formatMessage(messages.tootHeading)} + onClick={this.handleHeaderClick} + showBackButton + extraButton={( + <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><i className={`fa fa-${!isExpanded ? 'eye-slash' : 'eye'}`} /></button> + )} + /> + + <ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}> + {ancestors} + + <HotKeys handlers={handlers}> + <div className='focusable' tabIndex='0' aria-label={textForScreenReader(intl, status, false, !status.get('hidden'))}> + <DetailedStatus + status={status} + settings={settings} + onOpenVideo={this.handleOpenVideo} + onOpenMedia={this.handleOpenMedia} + expanded={isExpanded} + onToggleHidden={this.handleExpandedToggle} + domain={domain} + /> + + <ActionBar + status={status} + onReply={this.handleReplyClick} + onFavourite={this.handleFavouriteClick} + onReblog={this.handleReblogClick} + onBookmark={this.handleBookmarkClick} + onDelete={this.handleDeleteClick} + onDirect={this.handleDirectClick} + onMention={this.handleMentionClick} + onMute={this.handleMuteClick} + onMuteConversation={this.handleConversationMuteClick} + onBlock={this.handleBlockClick} + onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} + /> + </div> + </HotKeys> + + {descendants} + </div> + </ScrollContainer> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js new file mode 100644 index 000000000..724f1c764 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js @@ -0,0 +1,124 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from 'flavours/glitch/components/status_content'; +import Avatar from 'flavours/glitch/components/avatar'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import DisplayName from 'flavours/glitch/components/display_name'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import Link from 'flavours/glitch/components/link'; +import Toggle from 'react-toggle'; + +export default class ActionsModal extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + actions: PropTypes.arrayOf(PropTypes.shape({ + active: PropTypes.bool, + href: PropTypes.string, + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string, + on: PropTypes.bool, + onPassiveClick: PropTypes.func, + text: PropTypes.node, + })), + }; + + renderAction = (action, i) => { + if (action === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { + active, + href, + icon, + meta, + name, + on, + onClick, + onPassiveClick, + text, + } = action; + + return ( + <li key={name || i}> + <Link + className={classNames('link', { active })} + href={href} + onClick={on !== null && typeof on !== 'undefined' && onPassiveClick || onClick} + role={onClick ? 'button' : null} + > + {function () { + + // We render a `<Toggle>` if we were provided an `on` + // property, and otherwise show an `<Icon>` if available. + switch (true) { + case on !== null && typeof on !== 'undefined': + return ( + <Toggle + checked={on} + onChange={onPassiveClick || onClick} + /> + ); + case !!icon: + return ( + <Icon + className='icon' + fullwidth + icon={icon} + /> + ); + default: + return null; + } + }()} + {meta ? ( + <div> + <strong>{text}</strong> + {meta} + </div> + ) : <div>{text}</div>} + </Link> + </li> + ); + } + + render () { + const status = this.props.status && ( + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> + <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> + </a> + </div> + + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={this.props.status.get('account')} size={48} /> + </div> + + <DisplayName account={this.props.status.get('account')} /> + </a> + </div> + + <StatusContent status={this.props.status} /> + </div> + ); + + return ( + <div className='modal-root__modal actions-modal'> + {status} + + <ul className={classNames({ 'with-status': !!status })}> + {this.props.actions.map(this.renderAction)} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js new file mode 100644 index 000000000..0a914dce2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js @@ -0,0 +1,86 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import StatusContent from 'flavours/glitch/components/status_content'; +import Avatar from 'flavours/glitch/components/avatar'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import DisplayName from 'flavours/glitch/components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, +}); + +@injectIntl +export default class BoostModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleReblog = () => { + this.props.onReblog(this.props.status); + this.props.onClose(); + } + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state); + } + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { status, intl } = this.props; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + </div> + </div> + + <div className='boost-modal__action-bar'> + <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div> + <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} ref={this.setRef} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.js b/app/javascript/flavours/glitch/features/ui/components/bundle.js new file mode 100644 index 000000000..8f0d7b8b1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/bundle.js @@ -0,0 +1,107 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const emptyComponent = () => null; +const noop = () => { }; + +class Bundle extends React.Component { + + static propTypes = { + fetchComponent: PropTypes.func.isRequired, + loading: PropTypes.func, + error: PropTypes.func, + children: PropTypes.func.isRequired, + renderDelay: PropTypes.number, + onFetch: PropTypes.func, + onFetchSuccess: PropTypes.func, + onFetchFail: PropTypes.func, + } + + static defaultProps = { + loading: emptyComponent, + error: emptyComponent, + renderDelay: 0, + onFetch: noop, + onFetchSuccess: noop, + onFetchFail: noop, + } + + static cache = {} + + state = { + mod: undefined, + forceRender: false, + } + + componentWillMount() { + this.load(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fetchComponent !== this.props.fetchComponent) { + this.load(nextProps); + } + } + + componentWillUnmount () { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + load = (props) => { + const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + + if (fetchComponent === undefined) { + this.setState({ mod: null }); + return Promise.resolve(); + } + + onFetch(); + + if (Bundle.cache[fetchComponent.name]) { + const mod = Bundle.cache[fetchComponent.name]; + + this.setState({ mod: mod.default }); + onFetchSuccess(); + return Promise.resolve(); + } + + this.setState({ mod: undefined }); + + if (renderDelay !== 0) { + this.timestamp = new Date(); + this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); + } + + return fetchComponent() + .then((mod) => { + Bundle.cache[fetchComponent.name] = mod; + this.setState({ mod: mod.default }); + onFetchSuccess(); + }) + .catch((error) => { + this.setState({ mod: null }); + onFetchFail(error); + }); + } + + render() { + const { loading: Loading, error: Error, children, renderDelay } = this.props; + const { mod, forceRender } = this.state; + const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; + + if (mod === undefined) { + return (elapsed >= renderDelay || forceRender) ? <Loading /> : null; + } + + if (mod === null) { + return <Error onRetry={this.load} />; + } + + return children(mod); + } + +} + +export default Bundle; diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js new file mode 100644 index 000000000..3e979a250 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import Column from './column'; +import ColumnHeader from './column_header'; +import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; +import IconButton from 'flavours/glitch/components/icon_button'; + +const messages = defineMessages({ + title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, + body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, +}); + +class BundleColumnError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { intl: { formatMessage } } = this.props; + + return ( + <Column> + <ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} /> + <ColumnBackButtonSlim /> + <div className='error-column'> + <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> + {formatMessage(messages.body)} + </div> + </Column> + ); + } + +} + +export default injectIntl(BundleColumnError); diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js new file mode 100644 index 000000000..2c14a1e5c --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import IconButton from 'flavours/glitch/components/icon_button'; + +const messages = defineMessages({ + error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, + close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, +}); + +class BundleModalError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { onClose, intl: { formatMessage } } = this.props; + + // Keep the markup in sync with <ModalLoading /> + // (make sure they have the same dimensions) + return ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> + {formatMessage(messages.error)} + </div> + + <div className='error-modal__footer'> + <div> + <button + onClick={onClose} + className='error-modal__nav onboarding-modal__skip' + > + {formatMessage(messages.close)} + </button> + </div> + </div> + </div> + ); + } + +} + +export default injectIntl(BundleModalError); diff --git a/app/javascript/flavours/glitch/features/ui/components/column.js b/app/javascript/flavours/glitch/features/ui/components/column.js new file mode 100644 index 000000000..ab78414e0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column.js @@ -0,0 +1,74 @@ +import React from 'react'; +import ColumnHeader from './column_header'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { scrollTop } from 'flavours/glitch/util/scroll'; +import { isMobile } from 'flavours/glitch/util/is_mobile'; + +export default class Column extends React.PureComponent { + + static propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool, + name: PropTypes.string, + }; + + handleHeaderClick = () => { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + + handleScroll = debounce(() => { + if (typeof this._interruptScrollAnimation !== 'undefined') { + this._interruptScrollAnimation(); + } + }, 200) + + setRef = (c) => { + this.node = c; + } + + render () { + const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props; + + const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); + + const columnHeaderId = showHeading && heading.replace(/ /g, '-'); + const header = showHeading && ( + <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} /> + ); + return ( + <div + ref={this.setRef} + role='region' + data-column={name} + aria-labelledby={columnHeaderId} + className='column' + onScroll={this.handleScroll} + > + {header} + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/column_header.js b/app/javascript/flavours/glitch/features/ui/components/column_header.js new file mode 100644 index 000000000..e8bdd8054 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_header.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class ColumnHeader extends React.PureComponent { + + static propTypes = { + icon: PropTypes.string, + type: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + columnHeaderId: PropTypes.string, + }; + + handleClick = () => { + this.props.onClick(); + } + + render () { + const { icon, type, active, columnHeaderId } = this.props; + let iconElement = ''; + + if (icon) { + iconElement = <i className={`fa fa-fw fa-${icon} column-header__icon`} />; + } + + return ( + <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}> + <button onClick={this.handleClick}> + {iconElement} + {type} + </button> + </h1> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.js b/app/javascript/flavours/glitch/features/ui/components/column_link.js new file mode 100644 index 000000000..1b6d7d09e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + +const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => { + const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; + + if (href) { + return ( + <a href={href} className='column-link' data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + {badgeElement} + </a> + ); + } else if (to) { + return ( + <Link to={to} className='column-link'> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + {badgeElement} + </Link> + ); + } else { + const handleOnClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + return onClick(e); + } + return ( + <a href='#' onClick={onClick && handleOnClick} className='column-link' tabIndex='0'> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + {badgeElement} + </a> + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + onClick: PropTypes.func, + href: PropTypes.string, + method: PropTypes.string, + badge: PropTypes.node, +}; + +export default ColumnLink; diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.js b/app/javascript/flavours/glitch/features/ui/components/column_loading.js new file mode 100644 index 000000000..ba2d0824e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class ColumnLoading extends ImmutablePureComponent { + + static propTypes = { + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + icon: PropTypes.string, + }; + + static defaultProps = { + title: '', + icon: '', + }; + + render() { + let { title, icon } = this.props; + return ( + <Column> + <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> + <div className='scrollable' /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/column_subheading.js b/app/javascript/flavours/glitch/features/ui/components/column_subheading.js new file mode 100644 index 000000000..8160c4aa3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_subheading.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const ColumnSubheading = ({ text }) => { + return ( + <div className='column-subheading'> + {text} + </div> + ); +}; + +ColumnSubheading.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default ColumnSubheading; diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js new file mode 100644 index 000000000..0fe580b9b --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -0,0 +1,197 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import ReactSwipeableViews from 'react-swipeable-views'; +import { links, getIndex, getLink } from './tabs_bar'; +import { Link } from 'react-router-dom'; + +import BundleContainer from '../containers/bundle_container'; +import ColumnLoading from './column_loading'; +import DrawerLoading from './drawer_loading'; +import BundleColumnError from './bundle_column_error'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components'; + +import detectPassiveEvents from 'detect-passive-events'; +import { scrollRight } from 'flavours/glitch/util/scroll'; + +const componentMap = { + 'COMPOSE': Compose, + 'HOME': HomeTimeline, + 'NOTIFICATIONS': Notifications, + 'PUBLIC': PublicTimeline, + 'COMMUNITY': CommunityTimeline, + 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, + 'FAVOURITES': FavouritedStatuses, + 'BOOKMARKS': BookmarkedStatuses, + 'LIST': ListTimeline, +}; + +const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); + +const messages = defineMessages({ + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, +}); + +@component => injectIntl(component, { withRef: true }) +export default class ColumnsArea extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + columns: ImmutablePropTypes.list.isRequired, + swipeToChangeColumns: PropTypes.bool, + singleColumn: PropTypes.bool, + children: PropTypes.node, + }; + + state = { + shouldAnimate: false, + } + + componentWillReceiveProps() { + this.setState({ shouldAnimate: false }); + } + + componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl'); + + this.setState({ shouldAnimate: true }); + } + + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + const modifier = this.isRtlLayout ? -1 : 1; + this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier); + } + } + + handleSwipe = (index) => { + this.pendingIndex = index; + + const nextLinkTranslationId = links[index].props['data-preview-title-id']; + const currentLinkSelector = '.tabs-bar__link.active'; + const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`; + + // HACK: Remove the active class from the current link and set it to the next one + // React-router does this for us, but too late, feeling laggy. + document.querySelector(currentLinkSelector).classList.remove('active'); + document.querySelector(nextLinkSelector).classList.add('active'); + } + + handleAnimationEnd = () => { + if (typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = (node) => { + this.node = node; + } + + renderView = (link, index) => { + const columnIndex = getIndex(this.context.router.history.location.pathname); + const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); + const icon = link.props['data-preview-icon']; + + const view = (index === columnIndex) ? + React.cloneElement(this.props.children) : + <ColumnLoading title={title} icon={icon} />; + + return ( + <div className='columns-area' key={index}> + {view} + </div> + ); + } + + renderLoading = columnId => () => { + return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />; + } + + renderError = (props) => { + return <BundleColumnError {...props} />; + } + + render () { + const { columns, children, singleColumn, swipeToChangeColumns, intl } = this.props; + const { shouldAnimate } = this.state; + + const columnIndex = getIndex(this.context.router.history.location.pathname); + this.pendingIndex = null; + + if (singleColumn) { + const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><i className='fa fa-pencil' /></Link>; + + return columnIndex !== -1 ? [ + <ReactSwipeableViews key='content' index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}> + {links.map(this.renderView)} + </ReactSwipeableViews>, + + floatingActionButton, + ] : [ + <div className='columns-area'>{children}</div>, + + floatingActionButton, + ]; + } + + return ( + <div className='columns-area' ref={this.setRef}> + {columns.map(column => { + const params = column.get('params', null) === null ? null : column.get('params').toJS(); + const other = params && params.other ? params.other : {}; + + return ( + <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}> + {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />} + </BundleContainer> + ); + })} + + {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js new file mode 100644 index 000000000..970df30b6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js @@ -0,0 +1,81 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; + +@injectIntl +export default class ConfirmationModal extends React.PureComponent { + + static propTypes = { + message: PropTypes.node.isRequired, + confirm: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + secondary: PropTypes.string, + onSecondary: PropTypes.func, + onDoNotAsk: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(); + if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) { + this.props.onDoNotAsk(); + } + } + + handleSecondary = () => { + this.props.onClose(); + this.props.onSecondary(); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + setDoNotAskRef = (c) => { + this.doNotAskCheckbox = c; + } + + render () { + const { message, confirm, secondary, onDoNotAsk } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + {message} + </div> + + <div> + { onDoNotAsk && ( + <div className='confirmation-modal__do_not_ask_again'> + <input type='checkbox' id='confirmation-modal__do_not_ask_again-checkbox' ref={this.setDoNotAskRef} /> + <label for='confirmation-modal__do_not_ask_again-checkbox'> + <FormattedMessage id='confirmation_modal.do_not_ask_again' defaultMessage='Do not ask for confirmation again' /> + </label> + </div> + )} + <div className='confirmation-modal__action-bar'> + <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + {secondary !== undefined && ( + <Button text={secondary} onClick={this.handleSecondary} className='confirmation-modal__secondary-button' /> + )} + <Button text={confirm} onClick={this.handleClick} ref={this.setRef} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js new file mode 100644 index 000000000..72f7f30b9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js @@ -0,0 +1,614 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'flavours/glitch/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 'flavours/glitch/actions/compose'; +import IconButton from 'flavours/glitch/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 }); +} +/** Doodle canvas size options */ +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 canvas size? This will erase your current drawing!')) { + return; + } + + this.size = newSize; + }; + + handleClearBtn = () => { + if (this.undos.length > 1 && !confirm('Clear canvas? This will erase your current 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/flavours/glitch/features/ui/components/drawer_loading.js b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.js new file mode 100644 index 000000000..08b0d2347 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const DrawerLoading = () => ( + <div className='drawer'> + <div className='drawer__pager'> + <div className='drawer__inner' /> + </div> + </div> +); + +export default DrawerLoading; diff --git a/app/javascript/flavours/glitch/features/ui/components/embed_modal.js b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js new file mode 100644 index 000000000..b1643df1c --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import api from 'flavours/glitch/util/api'; + +@injectIntl +export default class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + api().post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.width = iframeDocument.body.scrollWidth; + this.iframe.height = iframeDocument.body.scrollHeight; + }).catch(error => { + this.props.onError(error); + }); + } + + setIframeRef = c => { + this.iframe = c; + } + + handleTextareaClick = (e) => { + e.target.select(); + } + + render () { + const { oembed } = this.state; + + return ( + <div className='modal-root__modal embed-modal'> + <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4> + + <div className='embed-modal__container'> + <p className='hint'> + <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> + </p> + + <input + type='text' + className='embed-modal__html' + readOnly + value={oembed && oembed.html || ''} + onClick={this.handleTextareaClick} + /> + + <p className='hint'> + <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> + </p> + + <iframe + className='embed-modal__iframe' + frameBorder='0' + ref={this.setIframeRef} + sandbox='allow-same-origin' + title='preview' + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js new file mode 100644 index 000000000..e0037a15f --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js @@ -0,0 +1,86 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from 'flavours/glitch/components/button'; +import StatusContent from 'flavours/glitch/components/status_content'; +import Avatar from 'flavours/glitch/components/avatar'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import DisplayName from 'flavours/glitch/components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, +}); + +@injectIntl +export default class FavouriteModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onFavourite: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleFavourite = () => { + this.props.onFavourite(this.props.status); + this.props.onClose(); + } + + handleAccountClick = (e) => { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state); + } + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { status, intl } = this.props; + + return ( + <div className='modal-root__modal favourite-modal'> + <div className='favourite-modal__container'> + <div className='status light'> + <div className='favourite-modal__status-header'> + <div className='favourite-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar account={status.get('account')} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + </div> + </div> + + <div className='favourite-modal__action-bar'> + <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-star' /></span> }} /></div> + <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} ref={this.setRef} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js new file mode 100644 index 000000000..57c92cc66 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -0,0 +1,122 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import ImageLoader from './image_loader'; +import classNames from 'classnames'; +import { changeUploadCompose } from 'flavours/glitch/actions/compose'; +import { getPointerPosition } from 'flavours/glitch/features/video'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onSave: (x, y) => { + dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + }, + +}); + +@connect(mapStateToProps, mapDispatchToProps) +export default class FocalPointModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + }; + + state = { + x: 0, + y: 0, + focusX: 0, + focusY: 0, + dragging: false, + }; + + componentWillMount () { + this.updatePositionFromMedia(this.props.media); + } + + componentWillReceiveProps (nextProps) { + if (this.props.media.get('id') !== nextProps.media.get('id')) { + this.updatePositionFromMedia(nextProps.media); + } + } + + componentWillUnmount () { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + } + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + + this.updatePosition(e); + this.setState({ dragging: true }); + } + + handleMouseMove = e => { + this.updatePosition(e); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + this.setState({ dragging: false }); + this.props.onSave(this.state.focusX, this.state.focusY); + } + + updatePosition = e => { + const { x, y } = getPointerPosition(this.node, e); + const focusX = (x - .5) * 2; + const focusY = (y - .5) * -2; + + this.setState({ x, y, focusX, focusY }); + } + + updatePositionFromMedia = media => { + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + + if (focusX && focusY) { + const x = (focusX / 2) + .5; + const y = (focusY / -2) + .5; + + this.setState({ x, y, focusX, focusY }); + } else { + this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 }); + } + } + + setRef = c => { + this.node = c; + } + + render () { + const { media } = this.props; + const { x, y, dragging } = this.state; + + const width = media.getIn(['meta', 'original', 'width']) || null; + const height = media.getIn(['meta', 'original', 'height']) || null; + + return ( + <div className='modal-root__modal video-modal focal-point-modal'> + <div className={classNames('focal-point', { dragging })} ref={this.setRef}> + <ImageLoader + previewSrc={media.get('preview_url')} + src={media.get('url')} + width={width} + height={height} + /> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js new file mode 100644 index 000000000..5e1cf75af --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js @@ -0,0 +1,160 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { LoadingBar } from 'react-redux-loading-bar'; +import ZoomableImage from './zoomable_image'; + +export default class ImageLoader extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + previewSrc: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + loading: true, + error: false, + width: null, + } + + removers = []; + canvas = null; + + get canvasContext() { + if (!this.canvas) { + return null; + } + this._canvasContext = this._canvasContext || this.canvas.getContext('2d'); + return this._canvasContext; + } + + componentDidMount () { + this.loadImage(this.props); + } + + componentWillReceiveProps (nextProps) { + if (this.props.src !== nextProps.src) { + this.loadImage(nextProps); + } + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + loadImage (props) { + this.removeEventListeners(); + this.setState({ loading: true, error: false }); + Promise.all([ + props.previewSrc && this.loadPreviewCanvas(props), + this.hasSize() && this.loadOriginalImage(props), + ].filter(Boolean)) + .then(() => { + this.setState({ loading: false, error: false }); + this.clearPreviewCanvas(); + }) + .catch(() => this.setState({ loading: false, error: true })); + } + + loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + this.canvasContext.drawImage(image, 0, 0, width, height); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = previewSrc; + this.removers.push(removeEventListeners); + }) + + clearPreviewCanvas () { + const { width, height } = this.canvas; + this.canvasContext.clearRect(0, 0, width, height); + } + + loadOriginalImage = ({ src }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = src; + this.removers.push(removeEventListeners); + }); + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + hasSize () { + const { width, height } = this.props; + return typeof width === 'number' && typeof height === 'number'; + } + + setCanvasRef = c => { + this.canvas = c; + if (c) this.setState({ width: c.offsetWidth }); + } + + render () { + const { alt, src, width, height, onClick } = this.props; + const { loading } = this.state; + + const className = classNames('image-loader', { + 'image-loader--loading': loading, + 'image-loader--amorphous': !this.hasSize(), + }); + + return ( + <div className={className}> + <LoadingBar loading={loading ? 1 : 0} className='loading-bar' style={{ width: this.state.width || width }} /> + {loading ? ( + <canvas + className='image-loader__preview-canvas' + ref={this.setCanvasRef} + width={width} + height={height} + /> + ) : ( + <ZoomableImage + alt={alt} + src={src} + onClick={onClick} + /> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js new file mode 100644 index 000000000..ce6660480 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -0,0 +1,222 @@ +import React from 'react'; +import ReactSwipeableViews from 'react-swipeable-views'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; +import classNames from 'classnames'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImageLoader from './image_loader'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +@injectIntl +export default class MediaModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + status: ImmutablePropTypes.map, + index: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + index: null, + navigationHidden: false, + }; + + handleSwipe = (index) => { + this.setState({ index: index % this.props.media.size }); + } + + handleNextClick = () => { + this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); + } + + handlePrevClick = () => { + this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); + } + + handleChangeIndex = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + this.setState({ index: index % this.props.media.size }); + } + + handleKeyDown = (e) => { + switch(e.key) { + case 'ArrowLeft': + this.handlePrevClick(); + e.preventDefault(); + e.stopPropagation(); + break; + case 'ArrowRight': + this.handleNextClick(); + e.preventDefault(); + e.stopPropagation(); + break; + } + } + + componentDidMount () { + window.addEventListener('keydown', this.handleKeyDown, false); + } + + componentWillUnmount () { + window.removeEventListener('keydown', this.handleKeyDown); + } + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + } + + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + + render () { + const { media, status, intl, onClose } = this.props; + const { navigationHidden } = this.state; + + const index = this.getIndex(); + let pagination = []; + + const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; + const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; + + if (media.size > 1) { + pagination = media.map((item, i) => { + const classes = ['media-modal__button']; + if (i === index) { + classes.push('media-modal__button--active'); + } + return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>); + }); + } + + const content = media.map((image) => { + const width = image.getIn(['meta', 'original', 'width']) || null; + const height = image.getIn(['meta', 'original', 'height']) || null; + + if (image.get('type') === 'image') { + return ( + <ImageLoader + previewSrc={image.get('preview_url')} + src={image.get('url')} + width={width} + height={height} + alt={image.get('description')} + key={image.get('url')} + onClick={this.toggleNavigation} + /> + ); + } else if (image.get('type') === 'video') { + const { time } = this.props; + + return ( + <Video + preview={image.get('preview_url')} + blurhash={image.get('blurhash')} + src={image.get('url')} + width={image.get('width')} + height={image.get('height')} + startTime={time || 0} + onCloseVideo={onClose} + detailed + alt={image.get('description')} + key={image.get('url')} + /> + ); + } else if (image.get('type') === 'gifv') { + return ( + <ExtendedVideoPlayer + src={image.get('url')} + muted + controls={false} + width={width} + height={height} + key={image.get('preview_url')} + alt={image.get('description')} + onClick={this.toggleNavigation} + /> + ); + } + + return null; + }).toArray(); + + // you can't use 100vh, because the viewport height is taller + // than the visible part of the document in some mobile + // browsers when it's address bar is visible. + // https://developers.google.com/web/updates/2016/12/url-bar-resizing + const swipeableViewsStyle = { + width: '100%', + height: '100%', + }; + + const containerStyle = { + alignItems: 'center', // center vertically + }; + + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + + return ( + <div className='modal-root__modal media-modal'> + <div + className='media-modal__closer' + role='presentation' + onClick={onClose} + > + <ReactSwipeableViews + style={swipeableViewsStyle} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onSwitching={this.handleSwitching} + index={index} + > + {content} + </ReactSwipeableViews> + </div> + + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + + {leftNav} + {rightNav} + + {status && ( + <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> + <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + </div> + )} + + <ul className='media-modal__pagination'> + {pagination} + </ul> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_loading.js b/app/javascript/flavours/glitch/features/ui/components/modal_loading.js new file mode 100644 index 000000000..b1c322154 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/modal_loading.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; + +// Keep the markup in sync with <BundleModalError /> +// (make sure they have the same dimensions) +const ModalLoading = () => ( + <div className='modal-root__modal error-modal'> + <div className='error-modal__body'> + <LoadingIndicator /> + </div> + <div className='error-modal__footer'> + <div> + <button className='error-modal__nav onboarding-modal__skip' /> + </div> + </div> + </div> +); + +export default ModalLoading; diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js new file mode 100644 index 000000000..303e05db6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Base from '../../../components/modal_root'; +import BundleContainer from '../containers/bundle_container'; +import BundleModalError from './bundle_modal_error'; +import ModalLoading from './modal_loading'; +import ActionsModal from './actions_modal'; +import MediaModal from './media_modal'; +import VideoModal from './video_modal'; +import BoostModal from './boost_modal'; +import FavouriteModal from './favourite_modal'; +import DoodleModal from './doodle_modal'; +import ConfirmationModal from './confirmation_modal'; +import FocalPointModal from './focal_point_modal'; +import { + OnboardingModal, + MuteModal, + ReportModal, + SettingsModal, + EmbedModal, + ListEditor, + ListAdder, + PinnedAccountsEditor, +} from 'flavours/glitch/util/async-components'; + +const MODAL_COMPONENTS = { + 'MEDIA': () => Promise.resolve({ default: MediaModal }), + 'ONBOARDING': OnboardingModal, + 'VIDEO': () => Promise.resolve({ default: VideoModal }), + 'BOOST': () => Promise.resolve({ default: BoostModal }), + 'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }), + 'DOODLE': () => Promise.resolve({ default: DoodleModal }), + 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, + 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, + 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), + 'EMBED': EmbedModal, + 'LIST_EDITOR': ListEditor, + 'LIST_ADDER':ListAdder, + 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), + 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, +}; + +export default class ModalRoot extends React.PureComponent { + + static propTypes = { + type: PropTypes.string, + props: PropTypes.object, + onClose: PropTypes.func.isRequired, + }; + + getSnapshotBeforeUpdate () { + return { visible: !!this.props.type }; + } + + componentDidUpdate (prevProps, prevState, { visible }) { + if (visible) { + document.body.classList.add('with-modals--active'); + } else { + document.body.classList.remove('with-modals--active'); + } + } + + renderLoading = modalId => () => { + return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; + } + + renderError = (props) => { + const { onClose } = this.props; + + return <BundleModalError {...props} onClose={onClose} />; + } + + render () { + const { type, props, onClose } = this.props; + const visible = !!type; + + return ( + <Base onClose={onClose} noEsc={props ? props.noEsc : false}> + {visible && ( + <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> + {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} + </BundleContainer> + )} + </Base> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js new file mode 100644 index 000000000..0202b1ab1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Button from 'flavours/glitch/components/button'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import { muteAccount } from 'flavours/glitch/actions/accounts'; +import { toggleHideNotifications } from 'flavours/glitch/actions/mutes'; + + +const mapStateToProps = state => { + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: state.getIn(['mutes', 'new', 'account']), + notifications: state.getIn(['mutes', 'new', 'notifications']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, + + onClose() { + dispatch(closeModal()); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + }; +}; + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class MuteModal extends React.PureComponent { + + static propTypes = { + isSubmitting: PropTypes.bool.isRequired, + account: PropTypes.object.isRequired, + notifications: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onToggleNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + toggleNotifications = () => { + this.props.onToggleNotifications(); + } + + render () { + const { account, notifications } = this.props; + + return ( + <div className='modal-root__modal mute-modal'> + <div className='mute-modal__container'> + <p> + <FormattedMessage + id='confirmations.mute.message' + defaultMessage='Are you sure you want to mute {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + <div> + <label htmlFor='mute-modal__hide-notifications-checkbox'> + <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> + {' '} + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + </label> + </div> + </div> + + <div className='mute-modal__action-bar'> + <Button onClick={this.handleCancel} className='mute-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' /> + </Button> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js new file mode 100644 index 000000000..3fda97afc --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js @@ -0,0 +1,310 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import classNames from 'classnames'; +import Permalink from 'flavours/glitch/components/permalink'; +import ComposeForm from 'flavours/glitch/features/compose/components/compose_form'; +import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar'; +import Search from 'flavours/glitch/features/compose/components/search'; +import ColumnHeader from './column_header'; +import { me } from 'flavours/glitch/util/initial_state'; + +const noop = () => { }; + +const messages = defineMessages({ + home_title: { id: 'column.home', defaultMessage: 'Home' }, + notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + local_title: { id: 'column.community', defaultMessage: 'Local timeline' }, + federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }, +}); + +const PageOne = ({ acct, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-one'> + <div style={{ flex: '0 0 auto' }}> + <div className='onboarding-modal__page-one__elephant-friend' /> + </div> + + <div> + <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> +); + +PageOne.propTypes = { + acct: PropTypes.string.isRequired, + domain: PropTypes.string.isRequired, +}; + +const PageTwo = ({ intl, myAccount }) => ( + <div className='onboarding-modal__page onboarding-modal__page-two'> + <div className='figure non-interactive'> + <div className='pseudo-drawer'> + <DrawerAccount account={myAccount} /> + <ComposeForm + privacy='public' + text='Awoo! #introductions' + spoilerText='' + suggestions={ [] } + /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p> + </div> +); + +PageTwo.propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageThree = ({ intl, myAccount }) => ( + <div className='onboarding-modal__page onboarding-modal__page-three'> + <div className='figure non-interactive'> + <Search + value='' + onChange={noop} + onSubmit={noop} + onClear={noop} + onShow={noop} + /> + + <div className='pseudo-drawer'> + <DrawerAccount account={myAccount} /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }} /></p> + <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p> + </div> +); + +PageThree.propTypes = { + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageFour = ({ domain, intl }) => ( + <div className='onboarding-modal__page onboarding-modal__page-four'> + <div className='onboarding-modal__page-four__columns'> + <div className='row'> + <div> + <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.' /></p> + </div> + + <div> + <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p> + </div> + </div> + + <div className='row'> + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div> + </div> + + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p> + </div> + </div> +); + +PageFour.propTypes = { + domain: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, +}; + +const PageSix = ({ admin, domain }) => { + let adminSection = ''; + + if (admin) { + adminSection = ( + <p> + <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} /> + <br /> + <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{ domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }} /> + </p> + ); + } + + return ( + <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='{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://joinmastodon.org/apps' 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> + ); +}; + +PageSix.propTypes = { + admin: ImmutablePropTypes.map, + domain: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + myAccount: state.getIn(['accounts', me]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class OnboardingModal extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired, + admin: ImmutablePropTypes.map, + }; + + state = { + currentIndex: 0, + }; + + componentWillMount() { + const { myAccount, admin, domain, intl } = this.props; + this.pages = [ + <PageOne acct={myAccount.get('acct')} domain={domain} />, + <PageTwo myAccount={myAccount} intl={intl} />, + <PageThree myAccount={myAccount} intl={intl} />, + <PageFour domain={domain} intl={intl} />, + <PageSix admin={admin} domain={domain} />, + ]; + }; + + componentDidMount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + componentWillUnmount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + handleSkip = (e) => { + e.preventDefault(); + this.props.onClose(); + } + + handleDot = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.setState({ currentIndex: i }); + } + + handlePrev = () => { + this.setState(({ currentIndex }) => ({ + currentIndex: Math.max(0, currentIndex - 1), + })); + } + + handleNext = () => { + const { pages } = this; + this.setState(({ currentIndex }) => ({ + currentIndex: Math.min(currentIndex + 1, pages.length - 1), + })); + } + + handleSwipe = (index) => { + this.setState({ currentIndex: index }); + } + + handleKeyUp = ({ key }) => { + switch (key) { + case 'ArrowLeft': + this.handlePrev(); + break; + case 'ArrowRight': + this.handleNext(); + break; + } + } + + handleClose = () => { + this.props.onClose(); + } + + render () { + const { pages } = this; + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + const nextOrDoneBtn = hasMore ? ( + <button + onClick={this.handleNext} + className='onboarding-modal__nav onboarding-modal__next' + > + <FormattedMessage id='onboarding.next' defaultMessage='Next' /> + </button> + ) : ( + <button + onClick={this.handleClose} + className='onboarding-modal__nav onboarding-modal__done' + > + <FormattedMessage id='onboarding.done' defaultMessage='Done' /> + </button> + ); + + return ( + <div className='modal-root__modal onboarding-modal'> + <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='onboarding-modal__pager'> + {pages.map((page, i) => { + const className = classNames('onboarding-modal__page__wrapper', { + 'onboarding-modal__page__wrapper--active': i === currentIndex, + }); + return ( + <div key={i} className={className}>{page}</div> + ); + })} + </ReactSwipeableViews> + + <div className='onboarding-modal__paginator'> + <div> + <button + onClick={this.handleSkip} + className='onboarding-modal__nav onboarding-modal__skip' + > + <FormattedMessage id='onboarding.skip' defaultMessage='Skip' /> + </button> + </div> + + <div className='onboarding-modal__dots'> + {pages.map((_, i) => { + const className = classNames('onboarding-modal__dot', { + active: i === currentIndex, + }); + return ( + <div + key={`dot-${i}`} + role='button' + tabIndex='0' + data-index={i} + onClick={this.handleDot} + className={className} + /> + ); + })} + </div> + + <div> + {nextOrDoneBtn} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js new file mode 100644 index 000000000..8be1d5856 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -0,0 +1,136 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { changeReportComment, changeReportForward, submitReport } from 'flavours/glitch/actions/reports'; +import { expandAccountTimeline } from 'flavours/glitch/actions/timelines'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container'; +import { OrderedSet } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Button from 'flavours/glitch/components/button'; +import Toggle from 'react-toggle'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => { + const accountId = state.getIn(['reports', 'new', 'account_id']); + + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: getAccount(state, accountId), + comment: state.getIn(['reports', 'new', 'comment']), + forward: state.getIn(['reports', 'new', 'forward']), + statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), + }; + }; + + return mapStateToProps; +}; + +@connect(makeMapStateToProps) +@injectIntl +export default class ReportModal extends ImmutablePureComponent { + + static propTypes = { + isSubmitting: PropTypes.bool, + account: ImmutablePropTypes.map, + statusIds: ImmutablePropTypes.orderedSet.isRequired, + comment: PropTypes.string.isRequired, + forward: PropTypes.bool, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleCommentChange = e => { + this.props.dispatch(changeReportComment(e.target.value)); + } + + handleForwardChange = e => { + this.props.dispatch(changeReportForward(e.target.checked)); + } + + handleSubmit = () => { + this.props.dispatch(submitReport()); + } + + handleKeyDown = e => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + componentDidMount () { + this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true })); + } + + componentWillReceiveProps (nextProps) { + if (this.props.account !== nextProps.account && nextProps.account) { + this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true })); + } + } + + render () { + const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props; + + if (!account) { + return null; + } + + const domain = account.get('acct').split('@')[1]; + + return ( + <div className='modal-root__modal report-modal'> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__comment'> + <p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p> + + <textarea + className='setting-text light' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleCommentChange} + onKeyDown={this.handleKeyDown} + disabled={isSubmitting} + autoFocus + /> + + {domain && ( + <div> + <p><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> + + <div className='setting-toggle'> + <Toggle id='report-forward' checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} /> + <label htmlFor='report-forward' className='setting-toggle__label'><FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /></label> + </div> + </div> + )} + + <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /> + </div> + + <div className='report-modal__statuses'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js new file mode 100644 index 000000000..b44a21a42 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { NavLink, withRouter } from 'react-router-dom'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { debounce } from 'lodash'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import { connect } from 'react-redux'; + +const mapStateToProps = state => ({ + unreadNotifications: state.getIn(['notifications', 'unread']), + showBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), +}); + +@connect(mapStateToProps) +class NotificationsIcon extends React.PureComponent { + static propTypes = { + unreadNotifications: PropTypes.number, + showBadge: PropTypes.bool, + }; + + render() { + const { unreadNotifications, showBadge } = this.props; + return ( + <span className='icon-badge-wrapper'> + <i className='fa fa-fw fa-bell' /> + { showBadge && unreadNotifications > 0 && <div className='icon-badge' />} + </span> + ); + } +} + +export const links = [ + <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, + <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, + + <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, + <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, + <NavLink className='tabs-bar__link primary' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><i className='fa fa-fw fa-search' /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, + + <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><i className='fa fa-fw fa-bars' /></NavLink>, +]; + +export function getIndex (path) { + return links.findIndex(link => link.props.to === path); +} + +export function getLink (index) { + return links[index].props.to; +} + +@injectIntl +@withRouter +export default class TabsBar extends React.PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + } + + setRef = ref => { + this.node = ref; + } + + handleClick = (e) => { + // Only apply optimization for touch devices, which we assume are slower + // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices + if (isUserTouching()) { + e.preventDefault(); + e.persist(); + + requestAnimationFrame(() => { + const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link')); + const currentTab = tabs.find(tab => tab.classList.contains('active')); + const nextTab = tabs.find(tab => tab.contains(e.target)); + const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)]; + + + if (currentTab !== nextTab) { + if (currentTab) { + currentTab.classList.remove('active'); + } + + const listener = debounce(() => { + nextTab.removeEventListener('transitionend', listener); + this.props.history.push(to); + }, 50); + + nextTab.addEventListener('transitionend', listener); + nextTab.classList.add('active'); + } + }); + } + + } + + render () { + const { intl: { formatMessage } } = this.props; + + return ( + <nav className='tabs-bar' ref={this.setRef}> + {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} + </nav> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/upload_area.js b/app/javascript/flavours/glitch/features/ui/components/upload_area.js new file mode 100644 index 000000000..11a10baf1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/upload_area.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadArea extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onClose: PropTypes.func, + }; + + handleKeyUp = (e) => { + const keyCode = e.keyCode; + if (this.props.active) { + switch(keyCode) { + case 27: + e.preventDefault(); + e.stopPropagation(); + this.props.onClose(); + break; + } + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + render () { + const { active } = this.props; + + return ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => + (<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div>) + } + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js new file mode 100644 index 000000000..3f742c260 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -0,0 +1,51 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; + +export default class VideoModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, + time: PropTypes.number, + onClose: PropTypes.func.isRequired, + }; + + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + + render () { + const { media, status, time, onClose } = this.props; + + const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>; + + return ( + <div className='modal-root__modal video-modal'> + <div> + <Video + preview={media.get('preview_url')} + blurhash={media.get('blurhash')} + src={media.get('url')} + startTime={time} + onCloseVideo={onClose} + link={link} + detailed + alt={media.get('description')} + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js new file mode 100644 index 000000000..3f6562bc9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const MIN_SCALE = 1; +const MAX_SCALE = 4; + +const getMidpoint = (p1, p2) => ({ + x: (p1.clientX + p2.clientX) / 2, + y: (p1.clientY + p2.clientY) / 2, +}); + +const getDistance = (p1, p2) => + Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); + +const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); + +export default class ZoomableImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + scale: MIN_SCALE, + } + + removers = []; + container = null; + image = null; + lastTouchEndTime = 0; + lastDistance = 0; + + componentDidMount () { + let handler = this.handleTouchStart; + this.container.addEventListener('touchstart', handler); + this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + handler = this.handleTouchMove; + // on Chrome 56+, touch event listeners will default to passive + // https://www.chromestatus.com/features/5093566007214080 + this.container.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container.removeEventListener('touchend', handler)); + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + handleTouchStart = e => { + if (e.touches.length !== 2) return; + + this.lastDistance = getDistance(...e.touches); + } + + handleTouchMove = e => { + const { scrollTop, scrollHeight, clientHeight } = this.container; + if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { + // prevent propagating event to MediaModal + e.stopPropagation(); + return; + } + if (e.touches.length !== 2) return; + + e.preventDefault(); + e.stopPropagation(); + + const distance = getDistance(...e.touches); + const midpoint = getMidpoint(...e.touches); + const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); + + this.zoom(scale, midpoint); + + this.lastMidpoint = midpoint; + this.lastDistance = distance; + } + + zoom(nextScale, midpoint) { + const { scale } = this.state; + const { scrollLeft, scrollTop } = this.container; + + // math memo: + // x = (scrollLeft + midpoint.x) / scrollWidth + // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth + // scrollWidth = clientWidth * scale + // scrollWidth' = clientWidth * nextScale + // Solve x = x' for nextScrollLeft + const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x; + const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; + + this.setState({ scale: nextScale }, () => { + this.container.scrollLeft = nextScrollLeft; + this.container.scrollTop = nextScrollTop; + }); + } + + handleClick = e => { + // don't propagate event to MediaModal + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + + setContainerRef = c => { + this.container = c; + } + + setImageRef = c => { + this.image = c; + } + + render () { + const { alt, src } = this.props; + const { scale } = this.state; + const overflow = scale === 1 ? 'hidden' : 'scroll'; + + return ( + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + title={alt} + src={src} + style={{ + transform: `scale(${scale})`, + transformOrigin: '0 0', + }} + onClick={this.handleClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js b/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js new file mode 100644 index 000000000..c9086c9bc --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; + +import Bundle from '../components/bundle'; + +import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from 'flavours/glitch/actions/bundles'; + +const mapDispatchToProps = dispatch => ({ + onFetch () { + dispatch(fetchBundleRequest()); + }, + onFetchSuccess () { + dispatch(fetchBundleSuccess()); + }, + onFetchFail (error) { + dispatch(fetchBundleFail(error)); + }, +}); + +export default connect(null, mapDispatchToProps)(Bundle); diff --git a/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js new file mode 100644 index 000000000..ba194a002 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import ColumnsArea from '../components/columns_area'; + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), + swipeToChangeColumns: state.getIn(['local_settings', 'swipe_to_change_columns']), +}); + +export default connect(mapStateToProps, null, null, { forwardRef: true })(ColumnsArea); diff --git a/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js new file mode 100644 index 000000000..63e994f92 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import LoadingBar from 'react-redux-loading-bar'; + +const mapStateToProps = (state, ownProps) => ({ + loading: state.get('loadingBar')[ownProps.scope || 'default'], +}); + +export default connect(mapStateToProps)(LoadingBar.WrappedComponent); diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js new file mode 100644 index 000000000..f074002e4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { closeModal } from 'flavours/glitch/actions/modal'; +import ModalRoot from '../components/modal_root'; + +const mapStateToProps = state => ({ + type: state.get('modal').modalType, + props: state.get('modal').modalProps, +}); + +const mapDispatchToProps = dispatch => ({ + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js new file mode 100644 index 000000000..283aa2373 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js @@ -0,0 +1,29 @@ +import { injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import { NotificationStack } from 'react-notification'; +import { dismissAlert } from 'flavours/glitch/actions/alerts'; +import { getAlerts } from 'flavours/glitch/selectors'; + +const mapStateToProps = (state, { intl }) => { + const notifications = getAlerts(state); + + notifications.forEach(notification => ['title', 'message'].forEach(key => { + const value = notification[key]; + + if (typeof value === 'object') { + notification[key] = intl.formatMessage(value); + } + })); + + return { notifications }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + onDismiss: alert => { + dispatch(dismissAlert(alert)); + }, + }; +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js new file mode 100644 index 000000000..e0c017f82 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js @@ -0,0 +1,75 @@ +import { connect } from 'react-redux'; +import StatusList from 'flavours/glitch/components/status_list'; +import { scrollTopTimeline } from 'flavours/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { createSelector } from 'reselect'; +import { debounce } from 'lodash'; +import { me } from 'flavours/glitch/util/initial_state'; + +const makeGetStatusIds = () => createSelector([ + (state, { type }) => state.getIn(['settings', type], ImmutableMap()), + (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), + (state) => state.get('statuses'), +], (columnSettings, statusIds, statuses) => { + const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); + let regex = null; + + try { + regex = rawRegex && new RegExp(rawRegex, 'i'); + } catch (e) { + // Bad regex, don't affect filters + } + + return statusIds.filter(id => { + if (id === null) return true; + + const statusForId = statuses.get(id); + let showStatus = true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); + } + + if (columnSettings.getIn(['shows', 'direct']) === false) { + showStatus = showStatus && statusForId.get('visibility') !== 'direct'; + } + + if (showStatus && regex && statusForId.get('account') !== me) { + const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); + showStatus = !regex.test(searchIndex); + } + + return showStatus; + }); +}); + +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + + const mapStateToProps = (state, { timelineId }) => ({ + statusIds: getStatusIds(state, { type: timelineId }), + isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), + isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), + hasMore: state.getIn(['timelines', timelineId, 'hasMore']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { timelineId }) => ({ + + onScrollToTop: debounce(() => { + dispatch(scrollTopTimeline(timelineId, true)); + }, 100), + + onScroll: debounce(() => { + dispatch(scrollTopTimeline(timelineId, false)); + }, 100), + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js new file mode 100644 index 000000000..13c71337a --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -0,0 +1,529 @@ +import React from 'react'; +import NotificationsContainer from './containers/notifications_container'; +import PropTypes from 'prop-types'; +import LoadingBarContainer from './containers/loading_bar_container'; +import TabsBar from './components/tabs_bar'; +import ModalContainer from './containers/modal_container'; +import { connect } from 'react-redux'; +import { Redirect, withRouter } from 'react-router-dom'; +import { isMobile } from 'flavours/glitch/util/is_mobile'; +import { debounce } from 'lodash'; +import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose'; +import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; +import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; +import { fetchFilters } from 'flavours/glitch/actions/filters'; +import { clearHeight } from 'flavours/glitch/actions/height_cache'; +import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers'; +import UploadArea from './components/upload_area'; +import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; +import Favico from 'favico.js'; +import { + Compose, + Status, + GettingStarted, + KeyboardShortcuts, + PublicTimeline, + CommunityTimeline, + AccountTimeline, + AccountGallery, + HomeTimeline, + Followers, + Following, + Reblogs, + Favourites, + DirectTimeline, + HashtagTimeline, + Notifications, + FollowRequests, + GenericNotFound, + FavouritedStatuses, + BookmarkedStatuses, + ListTimeline, + Blocks, + DomainBlocks, + Mutes, + PinnedStatuses, + Lists, + GettingStartedMisc, +} from 'flavours/glitch/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import { me } from 'flavours/glitch/util/initial_state'; +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 '../../../glitch/components/status'; + +const messages = defineMessages({ + beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, +}); + +const mapStateToProps = state => ({ + hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, + hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, + layout: state.getIn(['local_settings', 'layout']), + isWide: state.getIn(['local_settings', 'stretch']), + navbarUnder: state.getIn(['local_settings', 'navbar_under']), + dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, + unreadNotifications: state.getIn(['notifications', 'unread']), + showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), + hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']), +}); + +const keyMap = { + help: '?', + new: 'n', + search: 's', + forceNew: 'option+n', + focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], + reply: 'r', + favourite: 'f', + boost: 'b', + mention: 'm', + open: ['enter', 'o'], + openProfile: 'p', + moveDown: ['down', 'j'], + moveUp: ['up', 'k'], + back: 'backspace', + goToHome: 'g h', + goToNotifications: 'g n', + goToLocal: 'g l', + goToFederated: 'g t', + goToDirect: 'g d', + goToStart: 'g s', + goToFavourites: 'g f', + goToPinned: 'g p', + goToProfile: 'g u', + goToBlocked: 'g b', + goToMuted: 'g m', + goToRequests: 'g r', + toggleSpoiler: 'x', + bookmark: 'd', + toggleCollapse: 'shift+x', +}; + +@connect(mapStateToProps) +@injectIntl +@withRouter +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, + hasMediaAttachments: PropTypes.bool, + match: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + intl: PropTypes.object.isRequired, + dropdownMenuIsOpen: PropTypes.bool, + unreadNotifications: PropTypes.number, + showFaviconBadge: PropTypes.bool, + }; + + state = { + width: window.innerWidth, + draggingOver: false, + }; + + handleBeforeUnload = (e) => { + const { intl, hasComposingText, hasMediaAttachments } = this.props; + + if (hasComposingText || hasMediaAttachments) { + // Setting returnValue to any string causes confirmation dialog. + // Many browsers no longer display this text to users, + // but we set user-friendly message for other browsers, e.g. Edge. + e.returnValue = intl.formatMessage(messages.beforeUnload); + } + } + + handleResize = debounce(() => { + // The cached heights are no longer accurate, invalidate + this.props.dispatch(clearHeight()); + + this.setState({ width: window.innerWidth }); + }, 500, { + trailing: true, + }); + + handleDragEnter = (e) => { + e.preventDefault(); + + if (!this.dragTargets) { + this.dragTargets = []; + } + + if (this.dragTargets.indexOf(e.target) === -1) { + this.dragTargets.push(e.target); + } + + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + this.setState({ draggingOver: true }); + } + } + + handleDragOver = (e) => { + if (this.dataTransferIsText(e.dataTransfer)) return false; + e.preventDefault(); + e.stopPropagation(); + + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { + + } + + return false; + } + + handleDrop = (e) => { + if (this.dataTransferIsText(e.dataTransfer)) return; + e.preventDefault(); + + this.setState({ draggingOver: false }); + this.dragTargets = []; + + if (e.dataTransfer && e.dataTransfer.files.length >= 1) { + this.props.dispatch(uploadCompose(e.dataTransfer.files)); + } + } + + handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); + + if (this.dragTargets.length > 0) { + return; + } + + this.setState({ draggingOver: false }); + } + + dataTransferIsText = (dataTransfer) => { + return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1); + } + + closeUploadModal = () => { + this.setState({ draggingOver: false }); + } + + handleServiceWorkerPostMessage = ({ data }) => { + if (data.type === 'navigate') { + this.props.history.push(data.path); + } else { + console.warn('Unknown message type:', data.type); + } + } + + handleVisibilityChange = () => { + const visibility = !document[this.visibilityHiddenProp]; + this.props.dispatch(notificationsSetVisibility(visibility)); + } + + componentWillMount () { + if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support + this.visibilityHiddenProp = 'hidden'; + this.visibilityChange = 'visibilitychange'; + } else if (typeof document.msHidden !== 'undefined') { + this.visibilityHiddenProp = 'msHidden'; + this.visibilityChange = 'msvisibilitychange'; + } else if (typeof document.webkitHidden !== 'undefined') { + this.visibilityHiddenProp = 'webkitHidden'; + this.visibilityChange = 'webkitvisibilitychange'; + } + if (this.visibilityChange !== undefined) { + document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false); + this.handleVisibilityChange(); + } + + window.addEventListener('beforeunload', this.handleBeforeUnload, false); + window.addEventListener('resize', this.handleResize, { passive: true }); + document.addEventListener('dragenter', this.handleDragEnter, false); + document.addEventListener('dragover', this.handleDragOver, false); + document.addEventListener('drop', this.handleDrop, false); + document.addEventListener('dragleave', this.handleDragLeave, false); + document.addEventListener('dragend', this.handleDragEnd, false); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); + } + + this.favicon = new Favico({ animation:"none" }); + + this.props.dispatch(expandHomeTimeline()); + this.props.dispatch(expandNotifications()); + setTimeout(() => this.props.dispatch(fetchFilters()), 500); + } + + componentDidMount () { + this.hotkeys.__mousetrap__.stopCallback = (e, element) => { + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + }; + } + + shouldComponentUpdate (nextProps) { + if (nextProps.navbarUnder !== this.props.navbarUnder) { + // Avoid expensive update just to toggle a class + this.node.classList.toggle('navbar-under', nextProps.navbarUnder); + + return false; + } + + // Why isn't this working?!? + // return super.shouldComponentUpdate(nextProps, nextState); + return true; + } + + componentDidUpdate (prevProps) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.columnsAreaNode.handleChildrenContentChange(); + } + if (this.props.unreadNotifications != prevProps.unreadNotifications || + this.props.showFaviconBadge != prevProps.showFaviconBadge) { + if (this.favicon) { + this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + } + } + } + + componentWillUnmount () { + if (this.visibilityChange !== undefined) { + document.removeEventListener(this.visibilityChange, this.handleVisibilityChange); + } + + window.removeEventListener('beforeunload', this.handleBeforeUnload); + window.removeEventListener('resize', this.handleResize); + document.removeEventListener('dragenter', this.handleDragEnter); + document.removeEventListener('dragover', this.handleDragOver); + document.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragleave', this.handleDragLeave); + document.removeEventListener('dragend', this.handleDragEnd); + } + + setRef = c => { + this.node = c; + } + + setColumnsAreaRef = c => { + this.columnsAreaNode = c.getWrappedInstance(); + } + + handleHotkeyNew = e => { + e.preventDefault(); + + const element = this.node.querySelector('.composer--textarea textarea'); + + if (element) { + element.focus(); + } + } + + handleHotkeySearch = e => { + e.preventDefault(); + + const element = this.node.querySelector('.drawer--search input'); + + if (element) { + element.focus(); + } + } + + handleHotkeyForceNew = e => { + this.handleHotkeyNew(e); + this.props.dispatch(resetCompose()); + } + + handleHotkeyFocusColumn = e => { + const index = (e.key * 1) + 1; // First child is drawer, skip that + const column = this.node.querySelector(`.column:nth-child(${index})`); + if (!column) return; + const container = column.querySelector('.scrollable'); + + if (container) { + const status = container.querySelector('.focusable'); + + if (status) { + if (container.scrollTop > status.offsetTop) { + status.scrollIntoView(true); + } + status.focus(); + } + } + } + + handleHotkeyBack = () => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + this.props.history.goBack(); + } else { + this.props.history.push('/'); + } + } + + setHotkeysRef = c => { + this.hotkeys = c; + } + + handleHotkeyToggleHelp = () => { + if (this.props.location.pathname === '/keyboard-shortcuts') { + this.props.history.goBack(); + } else { + this.props.history.push('/keyboard-shortcuts'); + } + } + + handleHotkeyGoToHome = () => { + this.props.history.push('/timelines/home'); + } + + handleHotkeyGoToNotifications = () => { + this.props.history.push('/notifications'); + } + + handleHotkeyGoToLocal = () => { + this.props.history.push('/timelines/public/local'); + } + + handleHotkeyGoToFederated = () => { + this.props.history.push('/timelines/public'); + } + + handleHotkeyGoToDirect = () => { + this.props.history.push('/timelines/direct'); + } + + handleHotkeyGoToStart = () => { + this.props.history.push('/getting-started'); + } + + handleHotkeyGoToFavourites = () => { + this.props.history.push('/favourites'); + } + + handleHotkeyGoToPinned = () => { + this.props.history.push('/pinned'); + } + + handleHotkeyGoToProfile = () => { + this.props.history.push(`/accounts/${me}`); + } + + handleHotkeyGoToBlocked = () => { + this.props.history.push('/blocks'); + } + + handleHotkeyGoToMuted = () => { + this.props.history.push('/mutes'); + } + + handleHotkeyGoToRequests = () => { + this.props.history.push('/follow_requests'); + } + + render () { + const { width, draggingOver } = this.state; + const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = 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, + 'hicolor-privacy-icons': this.props.hicolorPrivacyIcons, + }); + + const handlers = { + help: this.handleHotkeyToggleHelp, + new: this.handleHotkeyNew, + search: this.handleHotkeySearch, + forceNew: this.handleHotkeyForceNew, + focusColumn: this.handleHotkeyFocusColumn, + back: this.handleHotkeyBack, + goToHome: this.handleHotkeyGoToHome, + goToNotifications: this.handleHotkeyGoToNotifications, + goToLocal: this.handleHotkeyGoToLocal, + goToFederated: this.handleHotkeyGoToFederated, + goToDirect: this.handleHotkeyGoToDirect, + goToStart: this.handleHotkeyGoToStart, + goToFavourites: this.handleHotkeyGoToFavourites, + goToPinned: this.handleHotkeyGoToPinned, + goToProfile: this.handleHotkeyGoToProfile, + goToBlocked: this.handleHotkeyGoToBlocked, + goToMuted: this.handleHotkeyGoToMuted, + goToRequests: this.handleHotkeyGoToRequests, + }; + + return ( + <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> + <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> + {navbarUnder ? null : (<TabsBar />)} + + <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='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> + <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> + <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> + <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} /> + <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} /> + <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> + <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} /> + <WrappedRoute path='/notifications' component={Notifications} content={children} /> + <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> + <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> + <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + + <WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} /> + + <WrappedRoute path='/statuses/new' component={Compose} content={children} /> + <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> + <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> + <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> + + <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> + <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> + <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> + <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> + <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> + + <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> + <WrappedRoute path='/blocks' component={Blocks} content={children} /> + <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> + <WrappedRoute path='/mutes' component={Mutes} content={children} /> + <WrappedRoute path='/lists' component={Lists} content={children} /> + <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} /> + + <WrappedRoute component={GenericNotFound} content={children} /> + </WrappedSwitch> + </ColumnsAreaContainer> + + <NotificationsContainer /> + {navbarUnder ? (<TabsBar />) : null} + <LoadingBarContainer className='loading-bar' /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js new file mode 100644 index 000000000..2e0d59d47 --- /dev/null +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -0,0 +1,520 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { fromJS } from 'immutable'; +import { throttle } from 'lodash'; +import classNames from 'classnames'; +import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; +import { displayMedia } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + hide: { id: 'video.hide', defaultMessage: 'Hide video' }, + expand: { id: 'video.expand', defaultMessage: 'Expand video' }, + close: { id: 'video.close', defaultMessage: 'Close video' }, + fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, + exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, +}); + +const formatTime = secondsNum => { + let hours = Math.floor(secondsNum / 3600); + let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); + let seconds = secondsNum - (hours * 3600) - (minutes * 60); + + if (hours < 10) hours = '0' + hours; + if (minutes < 10) minutes = '0' + minutes; + if (seconds < 10) seconds = '0' + seconds; + return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`; +}; + +export const findElementPosition = el => { + let box; + + if (el.getBoundingClientRect && el.parentNode) { + box = el.getBoundingClientRect(); + } + + if (!box) { + return { + left: 0, + top: 0, + }; + } + + const docEl = document.documentElement; + const body = document.body; + + const clientLeft = docEl.clientLeft || body.clientLeft || 0; + const scrollLeft = window.pageXOffset || body.scrollLeft; + const left = (box.left + scrollLeft) - clientLeft; + + const clientTop = docEl.clientTop || body.clientTop || 0; + const scrollTop = window.pageYOffset || body.scrollTop; + const top = (box.top + scrollTop) - clientTop; + + return { + left: Math.round(left), + top: Math.round(top), + }; +}; + +export const getPointerPosition = (el, event) => { + const position = {}; + const box = findElementPosition(el); + const boxW = el.offsetWidth; + const boxH = el.offsetHeight; + const boxY = box.top; + const boxX = box.left; + + let pageY = event.pageY; + let pageX = event.pageX; + + if (event.changedTouches) { + pageX = event.changedTouches[0].pageX; + pageY = event.changedTouches[0].pageY; + } + + position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)); + position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); + + return position; +}; + +@injectIntl +export default class Video extends React.PureComponent { + + static propTypes = { + preview: PropTypes.string, + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + sensitive: PropTypes.bool, + revealed: PropTypes.bool, + startTime: PropTypes.number, + onOpenVideo: PropTypes.func, + onCloseVideo: PropTypes.func, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + detailed: PropTypes.bool, + inline: PropTypes.bool, + preventPlayback: PropTypes.bool, + intl: PropTypes.object.isRequired, + cacheWidth: PropTypes.func, + blurhash: PropTypes.string, + link: PropTypes.node, + }; + + state = { + currentTime: 0, + duration: 0, + volume: 0.5, + paused: true, + dragging: false, + containerWidth: this.props.width, + fullscreen: false, + hovered: false, + muted: false, + revealed: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed, + }; + + componentWillReceiveProps (nextProps) { + if (nextProps.revealed === true) { + this.setState({ revealed: true }); + } + } + + // hard coded in components.scss + // any way to get ::before values programatically? + volWidth = 50; + volOffset = 70; + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setPlayerRef = c => { + this.player = c; + + if (c && c.offsetWidth && c.offsetWidth != this.state.containerWidth) { + if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth); + this.setState({ + containerWidth: c.offsetWidth, + }); + } + } + + setVideoRef = c => { + this.video = c; + + if (this.video) { + this.setState({ volume: this.video.volume, muted: this.video.muted }); + } + } + + setSeekRef = c => { + this.seek = c; + } + + setVolumeRef = c => { + this.volume = c; + } + + setCanvasRef = c => { + this.canvas = c; + + if (c && this.props.blurhash) { + this._decode(); + } + } + + handleMouseDownRoot = e => { + e.preventDefault(); + e.stopPropagation(); + } + + handlePlay = () => { + this.setState({ paused: false }); + } + + handlePause = () => { + this.setState({ paused: true }); + } + + handleTimeUpdate = () => { + this.setState({ + currentTime: Math.floor(this.video.currentTime), + duration: Math.floor(this.video.duration), + }); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; //x position within the element. + + if(!isNaN(x)) { + var slideamt = x; + if(x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + this.video.volume = slideamt; + this.setState({ volume: slideamt }); + } + }, 60); + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove, true); + document.addEventListener('mouseup', this.handleMouseUp, true); + document.addEventListener('touchmove', this.handleMouseMove, true); + document.addEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: true }); + this.video.pause(); + this.handleMouseMove(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove, true); + document.removeEventListener('mouseup', this.handleMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseMove, true); + document.removeEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: false }); + this.video.play(); + } + + handleMouseMove = throttle(e => { + const { x } = getPointerPosition(this.seek, e); + const currentTime = Math.floor(this.video.duration * x); + + if (!isNaN(currentTime)) { + this.video.currentTime = currentTime; + this.setState({ currentTime }); + } + }, 60); + + togglePlay = () => { + if (this.state.paused) { + this.video.play(); + } else { + this.video.pause(); + } + } + + toggleFullscreen = () => { + if (isFullscreen()) { + exitFullscreen(); + } else { + requestFullscreen(this.player); + } + } + + componentDidMount () { + document.addEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (this.props.blurhash) { + this._decode(); + } + } + + componentWillUnmount () { + document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + } + + componentDidUpdate (prevProps) { + if (this.player && this.player.offsetWidth && this.player.offsetWidth != this.state.containerWidth && !this.state.fullscreen) { + if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth); + this.setState({ + containerWidth: this.player.offsetWidth, + }); + } + if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) { + this.video.pause(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { + this._decode(); + } + } + + _decode () { + if (!this.canvas) return; + + const hash = this.props.blurhash; + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + handleFullscreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + toggleMute = () => { + this.video.muted = !this.video.muted; + this.setState({ muted: this.video.muted }); + } + + toggleReveal = () => { + if (this.state.revealed) { + this.video.pause(); + } + + this.setState({ revealed: !this.state.revealed }); + } + + handleLoadedData = () => { + if (this.props.startTime) { + this.video.currentTime = this.props.startTime; + this.video.play(); + } + } + + handleProgress = () => { + if (this.video.buffered.length > 0) { + this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 }); + } + } + + handleVolumeChange = () => { + this.setState({ volume: this.video.volume, muted: this.video.muted }); + } + + handleOpenVideo = () => { + const { src, preview, width, height, alt } = this.props; + + const media = fromJS({ + type: 'video', + url: src, + preview_url: preview, + description: alt, + width, + height, + }); + + this.video.pause(); + this.props.onOpenVideo(media, this.video.currentTime); + } + + handleCloseVideo = () => { + this.video.pause(); + this.props.onCloseVideo(); + } + + render () { + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props; + const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; + const progress = (currentTime / duration) * 100; + const playerStyle = {}; + + const volumeWidth = (muted) ? 0 : volume * this.volWidth; + const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth }); + + let { width, height } = this.props; + + if (inline && containerWidth) { + width = containerWidth; + height = containerWidth / (16/9); + + playerStyle.height = height; + } else if (inline) { + return (<div className={computedClass} ref={this.setPlayerRef} tabindex={0}></div>); + } + + let warning; + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + let preload; + + if (startTime || fullscreen || dragging) { + preload = 'auto'; + } else if (detailed) { + preload = 'metadata'; + } else { + preload = 'none'; + } + + return ( + <div + className={computedClass} + style={playerStyle} + ref={this.setPlayerRef} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + onMouseDown={this.handleMouseDownRoot} + tabIndex={0} + > + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> + + {revealed && <video + ref={this.setVideoRef} + src={src} + poster={preview} + preload={preload} + loop + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + width={width} + height={height} + volume={volume} + onClick={this.togglePlay} + onPlay={this.handlePlay} + onPause={this.handlePause} + onTimeUpdate={this.handleTimeUpdate} + onLoadedData={this.handleLoadedData} + onProgress={this.handleProgress} + onVolumeChange={this.handleVolumeChange} + />} + + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> + <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> + <span className='spoiler-button__overlay__label'>{warning}</span> + </button> + </div> + + <div className={classNames('video-player__controls', { active: paused || hovered })}> + <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> + <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} /> + <div className='video-player__seek__progress' style={{ width: `${progress}%` }} /> + + <span + className={classNames('video-player__seek__handle', { active: dragging })} + tabIndex='0' + style={{ left: `${progress}%` }} + /> + </div> + + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button> + <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button> + <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> + <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} /> + <span + className={classNames('video-player__volume__handle')} + tabIndex='0' + style={{ left: `${volumeHandleLoc}px` }} + /> + </div> + + {(detailed || fullscreen) && ( + <span> + <span className='video-player__time-current'>{formatTime(currentTime)}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(duration)}</span> + </span> + )} + + {link && <span className='video-player__link'>{link}</span>} + </div> + + <div className='video-player__buttons right'> + {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>} + {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} + {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>} + <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> + </div> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg b/app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg new file mode 100644 index 000000000..580c15a13 --- /dev/null +++ b/app/javascript/flavours/glitch/images/elephant_ui_disappointed.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="134.11569" width="134.61565" viewBox="0 0 134.61565 134.11569"><path d="M82.69963 103.86569c6.8 1.5 11 2.4 11.3-6.200005.3-8.6-1.8-17.3-1.8-17.3l-13.6 1.1 4.1 22.400005z" class="st32" fill="#3a434e" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.39963 112.96569c-.2 10.3-.6 17.5 6.5 17.4 7.1-.1 12.6 1.1 13.6-5.3 1.1-6.3 1.9-20.6.7-28.000005" class="st32" fill="#3a434e" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M86.39963 97.66569c-1.4-7.5-4.1-23.2-4.1-23.2s13.2-1.5 10.4-13c-2.7-11.4-7.5-22.6-11-31.1s-14.5-16.9-28.6-15.7c-19.2 1.6-25.6 7-31.6 23.1-5.4 14.4-10.4 47.2-8.9 63.3.8 8.7 5 13.7 14.4 13.5 9.4-.2 39.8-.8 49.8-2.8.3-.1.6-.1.9-.2" class="st33" fill="#56606b"/><path d="M85.89963 97.76569l-4.1-23.2c0-.3.1-.5.4-.6 2.6-.4 5.3-1.4 7.3-3.1 1-.8 1.9-1.9 2.4-3.1.5-1.2.7-2.5.6-3.9 0-1.3-.4-2.6-.7-4-.3-1.3-.7-2.7-1.1-4-.8-2.7-1.7-5.3-2.6-7.9-1.9-5.2-4-10.4-6.1-15.5-.5-1.3-1-2.6-1.7-3.8-.6-1.2-1.4-2.3-2.3-3.4-1.7-2.1-3.8-4-6-5.5-4.6-3-10-4.7-15.4-4.9-2.7-.1-5.5.3-8.2.6-2.7.4-5.5.9-8.1 1.7-2.6.8-5.1 1.9-7.3 3.5s-4.1 3.6-5.6 5.8c-1.5 2.3-2.8 4.7-3.9 7.3-.6 1.3-1.1 2.5-1.6 3.8-.4 1.3-.9 2.6-1.3 3.9-1.6 5.3-2.8 10.7-3.9 16.1-1 5.4-1.9 10.9-2.6 16.4-.7 5.5-1.2 11-1.3 16.6-.1 2.8-.1 5.5.1 8.3.1 2.8.5 5.5 1.6 8 1 2.5 2.9 4.6 5.4 5.7 2.4 1.1 5.2 1.3 8 1.3 5.6-.1 11.1-.2 16.7-.4 11.1-.4 22.2-.8 33.2-2.3.1 0 .2.1.2.2s0 .2-.1.2c-2.7.9-5.5 1.2-8.3 1.4-2.8.2-5.6.5-8.3.6-5.6.3-11.1.6-16.7.7-5.6.2-11.1.3-16.7.4-2.8.1-5.7-.1-8.4-1.3s-4.7-3.5-5.8-6.2c-1.1-2.6-1.5-5.5-1.6-8.3-.2-2.8-.2-5.6-.1-8.4.2-5.6.7-11.1 1.3-16.7.7-5.5 1.5-11 2.6-16.5s2.3-10.9 3.9-16.3c.4-1.3.9-2.7 1.3-4 .5-1.3 1-2.6 1.6-3.9 1.1-2.6 2.4-5.1 4-7.4 1.6-2.3 3.6-4.4 5.9-6.1 2.3-1.7 4.9-2.8 7.6-3.7 2.7-.8 5.5-1.4 8.2-1.7 2.8-.3 5.5-.7 8.4-.6 5.6.2 11.2 1.9 15.9 5 2.4 1.6 4.5 3.5 6.3 5.7.9 1.1 1.7 2.3 2.4 3.5.7 1.3 1.2 2.6 1.7 3.9 2.1 5.1 4.2 10.3 6.1 15.5.9 2.6 1.8 5.3 2.6 7.9.4 1.3.8 2.7 1.1 4 .3 1.3.7 2.7.8 4.2.1 1.4-.1 2.9-.7 4.3s-1.5 2.5-2.6 3.5c-2.3 1.9-5 2.8-7.9 3.3l.4-.6 4.1 23.2c0 .3-.1.5-.4.6-.3.1-.7.5-.7.2z"/><path d="M26.49963 114.06569c-4.7 0-7.4-2.1-10-4.4-2.3-2-3.2-4.6-3.4-8.6-.1-2.700005-.6-10.000005.4-18.800005 3.8.9 9.7 3.8 13.4 7.6 5.6 5.7 17.7 6.3 22.7 6.3h1.8l.1-.4s.5-2.6 1.8-5.2l.3-.6-.7-.1c-.4-.1-10.9-1.9-9.7-10.8.7-4.9 13.3-7.9 33.9-7.9 2.2 0 3.8 0 4.2.1l3.5 2.2c-1.5.5-2.6.6-2.6.6l-.5.1.1.5c0 .2 2.8 16.4 4.1 24 0 0-7.9 13.100005-8 13.000005-.1-.1-.3-.1-.3-.1-.3 0-.7.1-.9.1-9.9 1.7-39.6 2.4-49.3 2.6l-.9-.2z" class="st34" fill="#3a434e"/><path d="M45.89963 51.36569c-.7 0-1.4-.6-1.4-1.4v-5.1c0-.7.6-1.4 1.4-1.4.7 0 1.4.6 1.4 1.4v5.1c-.1.8-.7 1.4-1.4 1.4z"/><path d="M72.89963 30.365685c-3.5.4-2.7 2.9-1.2 3.5 1.5.6 3.7.1 4.3-1.6.4-1.6-1.3-2.1-3.1-1.9z" class="st35" fill="#4f5862"/><path d="M44.29963 53.965685c-.4.7-1.5.2-2.7-.6-1.2-.8-2.1-1.5-1.6-2.2.4-.7 1.6-.4 2.8.4 1.2.8 2 1.7 1.5 2.4z" class="st34" fill="#4f5862"/><path d="M27.29963 36.165685c0-5.6-3.7-9.4-7.9-9.8-4.2-.4-9-.3-14.0000002 11.3-5.00000001 11.6-6.7 15.7-2.6 17.9 4.1 2.2 9.5000002 1.5 11.3000002-1.4 0 0 5.3 3.8 9.7-3.8" class="st36" fill="#56606b" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M11.19963 40.565685c-2.7000002 5.1-2.7000002 7.7-.5 8.5 2.2.8 4.1.7 6.4-3 0 0 2 .7 4.9-4.1.9-1.5-.7-2.6-.7-2.6s-4.8 1.3-7.1-5l-3 6.2z" class="st34" fill="#3a434e" fill-opacity="0"/><path d="M9.7996298 43.365685l4.4000002-9s1.8 6.3 7.8 4.9" class="st7" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M27.89963 67.365685c-4.9.8-9.7 4.5-9.3 15.7.4 11.2.5 18.700005 6.1 20.000005 5.5 1.3 13.8.3 14.1-7.100005.3-7.4.3-16.1.3-16.1" class="st36" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M28.69963 102.96569c-1.4 0-2.8-.2-4.1-.5-1.2-.3-2.2-.9-3-2 .5.2 1.1.3 1.7.3 1.2 0 5.2-.5 5.8-7.200005.7-7.4 2.8-10.9 6.6-10.9.8 0 1.6.1 2.6.4 0 3.4-.1 8.3-.2 12.7-.2 6.700005-7.2 7.200005-9.4 7.200005z" class="st34" fill="#3a434e"/><path d="M50.69963 18.965685c-5.2 2.9-14.6 4.7-18.1-1.5-3-5.4 2.1-9.6999996 7.8-9.9999996 5.7-.3 7.6 1.2 7.6 1.2s1.9-5.9 9.3-7.69999998c3.9-1 6-.1 6.2 1.19999998 0 0 3.6-.9 4 3.5 0 0 3.9-.4 3.1 5.1999996-.8 5.6-10.6 10.1-17.7 6.4 0 0-1.1 1.2-2.2 1.7z" class="st33" fill="#56606b"/><path d="M40.79963 21.665685c-2.7 0-4.8-.9-6.3-2.3-.7-1.1-.8-2.9-.3-4.3.8-1.9 2.6-3.3 4.6-3.7 1.2-.2 2.6-.4 3.9-.4 3.3 0 6.2.8 7.3 1.9l.6.6.3-.7s.7-2 2.2-2c.2 0 .5.1.8.2 2.2.9 3.5 1.2 4.6 1.2.5 0 .9-.1 1.3-.2.1-.1.4-.1.6-.1.6 0 1.5.3 1.8.8.2.3.2.6.1 1l-.2.6h.7c.4 0 1.4.2 1.8.9.2.4.2 1-.2 1.7-1.8.8-3.8 1.2-5.7 1.2-2 0-4 0-5.6-.8 0 0-1.2 1.3-2.2 1.8-3.1 1.6-7 2.6-10.1 2.6z" class="st34" fill="#3a434e" fill-opacity=".94117647"/><path d="M61.79963 18.66569c-3.1.5-6.3.1-8.9-1.5.7.2 1.5.4 2.2.5.7.2 1.4.2 2.2.3.7.1 1.4 0 2.2 0 .7-.1 1.4-.1 2.2-.2h.1c.3 0 .5.1.6.4-.1.2-.3.5-.6.5z"/><path d="M37.59963 21.26569c-2.4-.4-4.8-2.1-5.7-4.5-.5-1.2-.7-2.6-.3-3.9.3-1.3 1.1-2.4 2.1-3.3 2-1.7 4.6-2.5 7.1-2.6 1.3-.1 2.5 0 3.8.1.6.1 1.3.2 1.9.4.6.2 1.2.4 1.9.8l-.8.2c.6-1.6 1.6-3 2.8-4.2 1.2-1.2 2.6-2.2 4.1-2.9 1.5-.7 3.2-1.1 4.8-1.3.8-.1 1.7-.1 2.6.1.4.1.9.3 1.3.6s.7.8.8 1.3l-.6-.4c.6-.1 1.2-.1 1.7 0 .6.1 1.1.4 1.6.8.4.4.7.9.9 1.5.1.3.2.5.2.8l.1.8-.5-.4c1 0 1.9.3 2.6.9.7.7 1 1.6 1.1 2.5.1.9 0 1.7-.1 2.5-.2.9-.5 1.7-1 2.4-.9 1.4-2.2 2.5-3.7 3.4-1.4.9-3 1.4-4.6 1.8-.3.1-.5-.1-.6-.4-.1-.3.1-.5.4-.6 1.5-.3 3-.9 4.3-1.7 1.3-.8 2.5-1.8 3.3-3.1.4-.6.7-1.3.8-2 .1-.7.2-1.5.1-2.2-.1-.7-.3-1.4-.8-1.9-.5-.4-1.2-.7-1.8-.7-.3 0-.5-.2-.5-.4l-.1-.7c-.1-.2-.1-.4-.2-.7-.2-.4-.4-.8-.7-1.1-.3-.3-.7-.5-1.1-.6-.4-.1-.9-.1-1.3 0-.3.1-.5-.1-.6-.4-.1-.5-.7-.9-1.4-1.1-.7-.2-1.5-.2-2.2-.1-1.5.2-3.1.6-4.5 1.2-1.4.7-2.7 1.6-3.8 2.7-1.1 1.1-2 2.5-2.5 3.8-.1.3-.4.4-.6.3h-.1c-.4-.2-1-.5-1.5-.6-.6-.1-1.2-.2-1.7-.3-1.2-.1-2.4-.2-3.6-.1-2.4.1-4.7.8-6.5 2.3-.9.7-1.6 1.7-1.9 2.8-.3 1.1-.2 2.3.2 3.4s1.1 2.1 1.9 2.9c.6.9 1.7 1.5 2.9 1.9z"/><path d="M63.49963 2.1656854c0 3.5-2.6 5.5-4.3 6.1m8.3-2.6c.2 3.4-3.3 5.1999996-3.3 5.1999996" class="st7" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.29963 84.765685c2.6 2.3 3 4.3-2.4 4.8-5.3.5-25.7 2.4-28.2 2.6-2.4.3-3.4 1.7-3.4 2.8 0 1.1.5 3.2 4 3.1 3.4-.1 23.8-1.5 30.4-2.4 6.6-.8 14.4-2.4 13.4-9s-5.4-8.7-5.4-8.7l-8.4 6.8z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.29963 84.765685c2.6 2.3 3 4.3-2.4 4.8-5.3.5-25.7 2.4-28.2 2.6-2.4.3-3.4 1.7-3.4 2.8 0 1.1.5 3.2 4 3.1 3.4-.1 23.8-1.5 30.4-2.4 6.6-.8 13.8-2.3 13.4-9-.3-5.5-3.1-7-4.4-8.1-.5-.1-1-.1-1.6-.2l-7.8 6.4z" fill="#625d28" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M102.69963 64.665685c5.4-.1 10.3-1.9 12.2-6.5 1.9-4.6 8.7-10.1 14.2-2.1 5.4 8.1 6.6 17.3 2.8 23.7-3.8 6.5-12.1 3.5-14.9-.5-2.7-4-8.6-2.9-14.5-2.7-5.9.2.2-11.9.2-11.9z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.89963 54.865685s10.2 21.3 13.5 26.8c3.2 5.5 12.9 6.2 17.4 3.5 4.5-2.7 7.3-7.3 8-15.1.7-7.9-2.4-14.9-10-15.2-7.6-.3-11.9 7.6-12.1 13.7" class="st36" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M65.89963 54.865685s10.2 21.3 13.5 26.8c3.2 5.5 12.9 6.2 17.4 3.5 4.5-2.7 7.3-7.3 8-15.1.7-7.9-2.4-14.9-10-15.2-7.6-.3-11.9 7.6-12.1 13.7" class="st36" fill="#56606b" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M90.19963 86.165685c-3.7 0-8.3-1.3-10.4-4.8-.9-1.5-2.4-4.3-4.4-8.4l5.9-.1c4 7.4 5.9 9.8 8 9.8 3.9 0 6-3.4 6.9-9.5.2-1.2.3-2.3.4-3.4.5-4.6.9-7.2 3.4-7.5.3 0 .6-.1.9-.1 2.1 0 2.5 1.2 3.1 2.8.1.2.2.5.2.7.1 1.3.1 2.7 0 4.2-.7 7.3-3.1 11.9-7.7 14.7-1.6 1-3.9 1.6-6.3 1.6z" class="st34" fill="#3a434e"/><path d="M89.19963 63.86569l-.3 6.6c-.1 1.1-.2 2.2-.4 3.3-.1.6-.3 1.1-.5 1.7-.3.5-.6 1.1-1.2 1.5-.2.1-.5.1-.7-.2-.1-.2-.1-.5.2-.7.7-.4 1.1-1.5 1.3-2.5.2-1 .4-2.1.5-3.2.2-2.2.3-4.4.5-6.6 0-.2.2-.3.3-.3.2.1.3.3.3.4z"/><path d="M52.29963 68.665685c-6.3.6-11.1 3.9-10 10.7 1.1 6.8 7.6 8.1 16 7.7 8.4-.4 26.4-1.3 26.4-1.3s-3.3-1.7-4.8-3.3c-.5-.6-1-1.4-1.6-2.5-1.6.1-15.5.8-22.7 1-3.4.1-3.8-1.2-3.9-1.8-.3-1.2.5-2.7 2.8-2.8 3.1-.2 10.8-.7 21.4-.7h11.5s.9-.3 1-9.1c0-.1-29.8 1.5-36.1 2.1z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M56.19963 86.665685c-8.8 0-12.6-2.3-13.5-7.4 0-.1 0-.2-.1-.4 1.2 1.5 3.5 2.7 5.6 2.7 1.4 0 2.6-.5 3.6-1.5.5.7 1.4 1.4 3.7 1.4h.4c6.9-.2 19.5-.8 22.1-.9.6 1.1 1.2 1.9 1.6 2.4.9 1 2.4 1.9 3.6 2.5-5 .3-18.1.9-24.7 1.2h-2.3z" class="st39" fill="#625d28"/><path d="M44.09963 57.865685c-2.2-.6-5.8-8.3-8.7-8.7-2.9-.3-6.6 1.6-3.2 8.5 3.4 6.9 8 10 14.3 8.2 6.3-1.8 12.7-5.1 14.5-8.3 1.8-3.2-.6-6.2-4.8-4.3-4.1 1.7-9.9 5.2-12.1 4.6z" fill="#b3bfcd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M43.09963 65.865685c-4.3 0-7.7-2.7-10.4-8.4-1.4-2.8-1.7-5.1-.8-6.4.8-1.3 2.3-1.4 2.9-1.4h.6c.4 0 .8.2 1.2.6-.7.1-1.3.5-1.6 1.2-.6 1.2-.3 2.9.9 4.7l.4.6c2.1 3.1 4.1 6 7.8 6 .9 0 1.9-.2 2.9-.6 5.6-2 9.4-3.6 11.1-5.4 1.2-1.3 1.9-2.6 1.7-3.6.5.2.9.5 1.1.9.5.8.4 1.9-.2 3-1.6 2.8-7.4 6.2-14.2 8.1-1.2.5-2.3.7-3.4.7z" class="st35" fill="#93a1b5"/><path d="M13.89963 107.66569c-.1 5.1 1.3 10.2 2.3 14.8 1.3 5.5 1.3 10.1 5.2 10.7 3.9.6 10.1.9 14.4 0 4.3-.9 4.1-5.2 4.5-8.2.4-3.1 0-10.7 0-10.7s-1.1-1.4-3-1.9" class="st34" fill="#3a434e"/><path d="M14.39963 107.66569c-.1 5.2 1.3 10.3 2.5 15.4l.8 3.9c.3 1.3.6 2.5 1.1 3.6.3.5.6 1 1.1 1.4.5.3 1 .5 1.6.6 1.3.2 2.6.3 3.9.4 2.6.2 5.2.2 7.8 0 1.3-.1 2.6-.3 3.7-.7 1.1-.4 1.9-1.4 2.3-2.6.4-1.2.5-2.4.7-3.7.1-.7.2-1.3.2-1.9.1-.6.1-1.3.1-1.9 0-2.6 0-5.2-.2-7.8l.1.3c-.1-.2-.3-.4-.5-.6l-.6-.6c-.4-.4-.9-.7-1.5-.9-.1 0-.1-.1-.1-.2s.1-.1.2-.1c.6.1 1.2.3 1.7.6.3.1.5.3.8.5.3.2.5.4.8.6.1.1.1.2.1.2.1 2.6.2 5.3.2 7.9 0 .7 0 1.3-.1 2s-.2 1.3-.2 2c-.1 1.3-.3 2.7-.7 4-.5 1.3-1.5 2.6-2.8 3.1-1.4.6-2.7.7-4 .8-2.7.2-5.3.2-8 0-1.3-.1-2.6-.2-4-.4-.7-.1-1.4-.4-2-.8-.6-.5-1-1.1-1.4-1.7-.6-1.3-.9-2.6-1.2-3.9l-.8-3.9c-1.1-5.1-2.6-10.3-2.5-15.6 0-.3.2-.5.5-.5.2 0 .4.2.4.5z"/><path d="M68.19963 86.665685l.4 4.6s.3 1.5 2.4 1.5c2.1-.1 2.2-2 2.2-2l-.1-4.5-4.9.4z" class="st37" fill="#737039" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M110.49963 71.465685c-.5 1.8.5 2.9 3.8 4.6 3.3 1.8 4 5.1 8.2 6 4.3.9 8.2-4.5 3.8-10.1-4.5-5.6-14.1-6.5-15.8-.5z" class="st39" fill="#625d28"/><circle r="1.7" cy="57.765686" cx="126.09963" fill="#99988c"/><path d="M17.39963 115.26569s.8 3.9 1.1 6.3c.3 2.4.9 3.8 5.9 3.2 5-.6 4.9-1.5 5.1-6.4.2-4.9-.1-7.4-3.7-7.6-3.6-.2-9.1.7-8.4 4.5z" class="st33" fill="#56606b"/><path fill="#3a434e" class="st34" d="M11.19963 40.565685c-2.7000002 5.1-2.7000002 7.7-.5 8.5 2.2.8 4.1.7 6.4-3 0 0 2 .7 4.9-4.1.9-1.5-.7-2.6-.7-2.6s-4.8 1.3-7.1-5l-3 6.2z"/></svg> \ No newline at end of file diff --git a/app/javascript/flavours/glitch/images/elephant_ui_working.svg b/app/javascript/flavours/glitch/images/elephant_ui_working.svg new file mode 100644 index 000000000..8ba475db0 --- /dev/null +++ b/app/javascript/flavours/glitch/images/elephant_ui_working.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 124.12477 127.91685" width="124.12476" height="127.91685"><path d="M72.584191 46.815676c-2.3-2.2-4.2-2.5-6.6-.6-2.4 1.9-2.1 4.8.9 7.6 3.1 2.9 4.7 4.1 6.7 5 2.1.9 5.4 2.5 10.5-2s10.2-11.1 9.4-14.7c-.8-3.6-4.1-1.8-6.8 1.2s-3.7 4-5.4 5.2c-1.5 1.3-3.8 3-8.7-1.7z" class="st0" style="fill:#93a1b5;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M116.384191 75.015676c0 6.3-3.9 9.8-9.1 9.8-5.3 0-9.9-3.5-9.9-9.8 0-6.3 4.3-10.3 9.5-10.3s9.5 4 9.5 10.3z" style="fill:#3a434e;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M54.184191 16.615676c-23 1.2-30.5 14.1-32.8 27.8-3 18.2-8.2 44.2-9.2 53.2s-1 16 6 22 11 5 23 7 19 0 20-8l16.8-1.1s14.5 5.5 18.8 6.9c4.3 1.4 10.6.5 12.1-7.1s.2-12.5-6.6-14.4c-6.8-1.9-10.6-2.9-10.6-2.9l4.4-30.1s17.4 1.6 22.6-20c0 0 3.9 1.1 4.8-2.8.9-3.9-2.6-6.2-5.6-4.8l-2.5-1s-.2-3.8-3.5-4.2c-2.1-.2-6 3.4-3 7.4 0 0-3.4 8.9-12 7.8-8.6-1.1-12.5-11.2-15-18.2s-10.7-18.3-27.7-17.5z" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M95.484191 69.915676c-.6 0-1.2 0-1.8-.1-4.2-.2-10.9-2.4-17-7.8-.7-1-.4-2-.2-2.5.5-1.1 1.7-1.8 3-1.8.8 0 1.5.3 2.2.8 3 2.2 7.8 5.1 13.8 5.1.9 0 1.8-.1 2.7-.2 7.2-1 12.1-5.8 14.3-9.9.6-1.2 1.3-2.5 1.8-3.5.2-.5.3-.7.6-.9.3-.2 1 .2 1 .2l2.1.8c-4.5 17.8-17.4 19.2-21.2 19.2h-1.3v.6z" class="st3" style="fill:#3a434e;opacity:.98;fill-opacity:1"/><path d="M48.884191 126.915676c-2.2 0-4.7-.2-7.6-.7-2.8-.5-5.1-.8-7.1-1-6.9-.9-10.3-1.3-15.6-5.9-7-6-6.8-13-5.8-21.6.3-2.3.8-5.9 1.7-11.2 3.1 1.4 6.1 2.2 8.7 2.2 3.1 0 5.4-1.2 6.6-3.4 1.6 1.9 6.9 7.3 13.3 7.3 1 0 1.9-.1 2.8-.4 3.5-1 19.8-2.1 46.9-3.4l-1.7 11.7.4.1s3.8 1 10.6 2.9c6.1 1.7 7.9 5.6 6.3 13.8-1.3 6.6-6.2 7.3-8.2 7.3-1.1 0-2.2-.2-3.3-.5-4.2-1.4-18.6-6.8-18.7-6.9h-.1l-17.3 1.2-.1.4c-.7 5.4-4.5 8.1-11.8 8.1z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M41.184191 103.415676c-3.8-1.4-6-1.4-7.7-1.4-1.8 0-4.6 3.3 1.4 5.4 6 2.1 10.3 3.4 10.3 3.4s1.8-2.1 3.5-2.9c1.6-.8 2.3-.9 2.3-.9l-9.8-3.6z" style="fill:#56606b;fill-opacity:1"/><path d="M27.584191 38.615676c1.2-5-2.1-8.2-5.7-9.2-3.5-1-8.4-1.7-13.9 6.9s-9.5 16.5-6.4 20.6c3.1 4.1 9.3 3.4 11.8-.8 0 0 5.7 3.8 9.5-4.2" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M10.884191 45.115676c-1.6 2.5-.8 5 2 5.9 2.7 1 5-1.5 6.5-3.8 1.6-2.3 3.6-5.9 3.6-5.9s-3.7 1.2-5.6-.2c-2-1.4-1.5-3.8-1.5-3.8l-5 7.8z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M22.684191 41.415676c-2.6 1.1-6.8.6-6.9-4.1 0 0-5.1 7.6-5.9 9.6" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M67.584191 5.215676c0-3.4-3.9-2.6-3.9-2.6 0-1.2-2-3.5-8.5-.6-6.4 2.9-7.3 6-7.3 6-3.8-1.7-9.6-2.6-13.5.8-3.9 3.4-4.3 10 2.3 13.5 0 0 2.9.9 7.7-.4 4.8-1.3 7.7-3.3 7.7-3.3s3.7 2.3 9 .6c5.3-1.7 9.9-4.5 10.3-10.1.5-5.7-3.8-3.9-3.8-3.9z" style="fill:#56606b;fill-opacity:1"/><path d="M67.084191 16.315676c-1-5.5-7-3-7-3 .5-2.1-3-4.1-5.5-2.7-2.5 1.4-6.6-.1-6.6-.1-6.4-4.4-14.3-2.1-16.1 2-1.1 3.3.2 7.3 4.8 9.7 0 0 2.9.9 7.7-.4 4.8-1.3 7.7-3.3 7.7-3.3s3.7 2.3 9 .6c2.3-.6 4.3-1.5 6-2.8 0 .1 0 0 0 0z" style="fill:#3a434e;fill-opacity:1"/><path d="M36.684191 22.715676c-.1 0-.2 0-.2-.1-3.1-1.6-5-4.1-5.4-7-.3-2.7.8-5.4 3-7.3 3.9-3.3 9.5-2.8 13.6-1.1.5-1.1 2.2-3.5 7.3-5.8 4.5-2 6.9-1.5 8-.8.6.4.9.9 1.1 1.3.7-.1 2-.1 3 .7.5.4.9 1 1 1.8.7-.1 1.8-.2 2.7.4 1 .7 1.4 2.1 1.2 4.1-.4 5-3.9 8.5-10.7 10.6-5.5 1.7-9.3-.6-9.4-.7-.2-.1-.3-.5-.2-.7.1-.2.5-.3.7-.2 0 0 3.6 2.2 8.6.6 6.3-2 9.6-5.1 10-9.7.1-1.6-.2-2.8-.8-3.3-.9-.7-2.3-.1-2.3-.1-.2.1-.3 0-.5 0-.1-.1-.2-.2-.2-.4 0-.8-.2-1.3-.6-1.7-.9-.8-2.6-.4-2.7-.4-.1 0-.3 0-.4-.1-.1-.1-.2-.2-.2-.4 0-.3-.2-.7-.7-1-.6-.4-2.6-1.1-7.1.8-6.1 2.7-7 5.6-7 5.7 0 .1-.1.3-.3.3-.1.1-.3.1-.4 0-3.9-1.7-9.3-2.4-13 .8-1.9 1.7-2.9 4.1-2.6 6.4.3 2.5 2 4.7 4.8 6.2.2.1.3.4.2.7-.1.3-.3.4-.5.4z"/><path d="M40.584191 84.115676s6.3 16.8 7.1 19.3c.8 2.5 1.8 3.4 7.3 3 5.5-.4 6.7-21.5 6.7-21.5l-21.1-.8z" style="fill:#191b22;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M51.084191 103.415676c-1.7-2.1-1.9-4.2-1.9-4.2l2-10.9 3-1.4-3.1 16.5zm33.9-35.3l-23.9 1.9 1.2 8 25.2 1.1 4.6-9c-2.3-.3-4.7-.9-7.1-2z" class="st9" style="fill:#191b22;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M28.484191 82.915676c7.2 9.4 12.7 11.4 21.8 7.7 8.5-3.4 15.4-9 15.1-15-.3-6-2.1-10.3-9.1-9.8-2.3.2-6.8 2.8-9.6 4.4-1.8 1-4.2 2.2-6 .4-1.8-1.8-4.3-4.4-4.3-4.4" class="st2" style="fill:#56606b;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M49.284191 80.515676c-.3-.2-.5-.4-.7-.6-1.2.7-2.3 1.3-2.8 1.6-2 1.2-3.8.5-4.7-.3-.9-.9-2.3-2.4-5.5-1.5-3.7 1-4.5 5.7-2.5 8.4 6.5 6.1 12.8 4.1 15.2 3.3 2.4-.8 6.3-2.7 6.3-2.7l.6-6c-2-.8-4.1-.9-5.9-2.2z" class="st3" style="fill:#3a434e;fill-opacity:1"/><path d="M28.484191 82.915676c7.2 9.4 12.7 11.4 21.8 7.7 8.5-3.4 15.4-9 15.1-15-.3-6-2.1-10.3-9.1-9.8-2.3.2-6.8 2.8-9.6 4.4-1.8 1-4.2 2.2-6 .4-1.8-1.8-4.3-4.4-4.3-4.4m35.4-8.6c6.5 8.3 15.5 12.5 21.8 12.7" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M53.184191 104.415676c-1.6.1-2.7-1.1-2.4-2.7l4.9-25.9c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6.5 3.7.7 4.6.2.9.3 3 .1 4.6l-2.2 21.3c-.2 1.6-1.7 3.1-3.3 3.3l-45.6 4z" style="fill:#191b22;fill-opacity:1"/><path d="M53.184191 104.415676c-1.6.1-2.7-1.1-2.4-2.7l4.9-25.9c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6.5 3.7.7 4.6.2.9.3 3 .1 4.6l-2.2 21.3c-.2 1.6-1.7 3.1-3.3 3.3l-45.6 4z" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M55.684191 105.915676c-1.6.1-2.3-.4-2-2l4.4-25.6c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6 1.3 2.9 2.5 2.8 1.2-.1 1.9 1.2 1.6 2.8l-5.2 24.9c-.3 1.6-2 3.1-3.6 3.2l-45.5 3.1z" style="fill:#191b22;fill-opacity:1"/><path d="M53.184191 104.315676l4.9-26c.3-1.6 1.9-3 3.6-3.1l26.9-1.7c1.6-.1 3.6-1.4 4.4-2.9l.7-1.3c.7-1.5 2.7-2.8 4.4-2.9l4.5-.3c1.6-.1 3.1 1.1 3.3 2.8v.2c.2 1.6 1.3 2.9 2.5 2.8 1.2-.1 1.9 1.2 1.6 2.8l-5.2 24.9c-.3 1.6-2 3.1-3.6 3.2l-46.7 3.7m9.2-103.9c-.3 2.9-2.9 4.9-4.1 5.8m8-3.2c-.7 3.5-4 6-4 6" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M39.884191 53.615676c-2.3-2.2-4.2-2.5-6.6-.6-2.4 1.9-2.1 4.8.9 7.6 3.1 2.9 4.7 4.1 6.7 5 2 .9 5.4 2.5 10.5-2s10.2-11.1 9.4-14.7c-.8-3.6-4.1-1.8-6.8 1.2s-3.7 4-5.4 5.2c-1.7 1.2-3.8 3-8.7-1.7z" class="st0" style="fill:#93a1b5;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;fill-opacity:1"/><path d="M44.384191 61.315676c-2.3 0-4.7-1.2-6.6-3.1-.9-.9-1.1-2-.7-2.9.3-.8 1.1-1.3 1.8-1.3.2 0 .5 0 .7.1 2.3 2.1 4.2 3.2 5.9 3.2 1.6 0 2.7-.8 3.5-1.4 1.7-1.3 2.8-2.3 5.5-5.3.9-1.1 1.9-1.9 2.7-2.4.3.2.6.4.7.8.2.8-.2 2-.7 2.7-.9 1.3-4.9 5.4-9 8.4-1.1.7-2.4 1.2-3.8 1.2z" style="fill:#b3bfcd;fill-opacity:1"/><path d="M45.784191 50.115676c-.7 0-1.4-.6-1.4-1.4v-6.1c0-.7.6-1.4 1.4-1.4.7 0 1.4.6 1.4 1.4v6.1c0 .8-.6 1.4-1.4 1.4z"/><path d="M61.184191 118.215676c.7-7.1-3.5-10.6-9.2-11.1-5.7-.5-10.2 6.8-9.1 13.1 1.1 6.3 6.7 7.2 6.7 7.2" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M52.084191 107.515676c-2.2-.7-4.3-1.4-6.5-2.2-2.1-.8-4.3-1.5-6.4-2.3-.1 0-.2-.2-.1-.3 0-.1.2-.2.3-.2 2.2.6 4.4 1.3 6.5 1.9 2.2.7 4.3 1.3 6.5 2 .3.1.4.4.3.6-.1.4-.4.6-.6.5zm25.4 10.1c-.2-1.4-.2-2.9.1-4.2.2-1.4.6-2.7 1.1-4-.3 1.3-.5 2.7-.6 4.1 0 1.4.1 2.7.4 4 .1.3-.1.5-.3.6-.2.1-.6-.1-.7-.5 0 .1 0 .1 0 0z"/><path d="M104.284191 103.615676c-3.6-.7-8.5 2.1-9.5 9.7s2.1 10.7 5.3 11.6m15.1-83.5l-.39999 1.4m-1.90001-1.2c2.4 1.7 6.4 3.4 6.4 3.4m-1.6-2.6l-.60001 1.59999" class="st5" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M47.484191 79.215676c3.2 2.7 7 3.3 7 3.3" style="fill:none;stroke:#000;stroke-miterlimit:10"/><path d="M69.284191 24.315676c-3.5.4-2.7 2.9-1.2 3.5 1.5.6 3.7.1 4.3-1.6.4-1.6-1.3-2.1-3.1-1.9z" style="fill:#4f5862;fill-opacity:1"/></svg> \ No newline at end of file diff --git a/app/javascript/flavours/glitch/images/glitch-preview.jpg b/app/javascript/flavours/glitch/images/glitch-preview.jpg new file mode 100644 index 000000000..fc5c42043 --- /dev/null +++ b/app/javascript/flavours/glitch/images/glitch-preview.jpg Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-0.png b/app/javascript/flavours/glitch/images/mbstobon-ui-0.png new file mode 100644 index 000000000..25e1707c9 --- /dev/null +++ b/app/javascript/flavours/glitch/images/mbstobon-ui-0.png Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-1.png b/app/javascript/flavours/glitch/images/mbstobon-ui-1.png new file mode 100644 index 000000000..64cf3cbf3 --- /dev/null +++ b/app/javascript/flavours/glitch/images/mbstobon-ui-1.png Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-2.png b/app/javascript/flavours/glitch/images/mbstobon-ui-2.png new file mode 100644 index 000000000..b767a9122 --- /dev/null +++ b/app/javascript/flavours/glitch/images/mbstobon-ui-2.png Binary files differdiff --git a/app/javascript/flavours/glitch/images/mbstobon-ui-3.png b/app/javascript/flavours/glitch/images/mbstobon-ui-3.png new file mode 100644 index 000000000..a1fb642a0 --- /dev/null +++ b/app/javascript/flavours/glitch/images/mbstobon-ui-3.png Binary files differdiff --git a/app/javascript/flavours/glitch/images/wave-drawer-glitched.png b/app/javascript/flavours/glitch/images/wave-drawer-glitched.png new file mode 100644 index 000000000..2290663db --- /dev/null +++ b/app/javascript/flavours/glitch/images/wave-drawer-glitched.png Binary files differdiff --git a/app/javascript/flavours/glitch/images/wave-drawer.png b/app/javascript/flavours/glitch/images/wave-drawer.png new file mode 100644 index 000000000..ca9f9e1d8 --- /dev/null +++ b/app/javascript/flavours/glitch/images/wave-drawer.png Binary files differdiff --git a/app/javascript/flavours/glitch/locales/ar.js b/app/javascript/flavours/glitch/locales/ar.js new file mode 100644 index 000000000..1081147d5 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ar.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ar.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/bg.js b/app/javascript/flavours/glitch/locales/bg.js new file mode 100644 index 000000000..979039376 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/bg.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/bg.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ca.js b/app/javascript/flavours/glitch/locales/ca.js new file mode 100644 index 000000000..baf76bd6f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ca.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ca.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/de.js b/app/javascript/flavours/glitch/locales/de.js new file mode 100644 index 000000000..ce6453623 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/de.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/de.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/en.js b/app/javascript/flavours/glitch/locales/en.js new file mode 100644 index 000000000..90e924d4a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/en.js @@ -0,0 +1,67 @@ +import inherited from 'mastodon/locales/en.json'; + +const messages = { + '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.show_action_bar': 'Show action buttons in collapsed toots', + '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', + + 'media_gallery.sensitive': 'Sensitive', + + 'favourite_modal.combo': 'You can press {combo} to skip this next time', + + 'home.column_settings.show_direct': 'Show DMs', + + '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', + + 'compose.attach.upload': 'Upload a file', + 'compose.attach.doodle': 'Draw something', + 'compose.attach': 'Attach...', + + 'advanced_options.local-only.short': 'Local-only', + 'advanced_options.local-only.long': 'Do not post to other instances', + 'advanced_options.local-only.tooltip': 'This post is local-only', + 'advanced_options.icon_title': 'Advanced options', + 'advanced_options.threaded_mode.short': 'Threaded mode', + 'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting', + 'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled', +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/eo.js b/app/javascript/flavours/glitch/locales/eo.js new file mode 100644 index 000000000..04192f506 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/eo.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/eo.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/es.js b/app/javascript/flavours/glitch/locales/es.js new file mode 100644 index 000000000..456df3c47 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/es.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/es.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/fa.js b/app/javascript/flavours/glitch/locales/fa.js new file mode 100644 index 000000000..d82461a1a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/fa.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/fa.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/fi.js b/app/javascript/flavours/glitch/locales/fi.js new file mode 100644 index 000000000..11c3cd082 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/fi.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/fi.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/fr.js b/app/javascript/flavours/glitch/locales/fr.js new file mode 100644 index 000000000..8562f5594 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/fr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/fr.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/he.js b/app/javascript/flavours/glitch/locales/he.js new file mode 100644 index 000000000..99516ee0c --- /dev/null +++ b/app/javascript/flavours/glitch/locales/he.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/he.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/hr.js b/app/javascript/flavours/glitch/locales/hr.js new file mode 100644 index 000000000..dbf9b4b9f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/hr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/hr.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/hu.js b/app/javascript/flavours/glitch/locales/hu.js new file mode 100644 index 000000000..1f0849af3 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/hu.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/hu.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/id.js b/app/javascript/flavours/glitch/locales/id.js new file mode 100644 index 000000000..07e5f7e56 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/id.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/id.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/io.js b/app/javascript/flavours/glitch/locales/io.js new file mode 100644 index 000000000..74ea6fae6 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/io.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/io.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/it.js b/app/javascript/flavours/glitch/locales/it.js new file mode 100644 index 000000000..90f543093 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/it.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/it.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js new file mode 100644 index 000000000..c323956c6 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ja.js @@ -0,0 +1,90 @@ +import inherited from 'mastodon/locales/ja.json'; + +const messages = { + 'getting_started.open_source_notice': 'Glitchsocは{Mastodon}によるフリーなオープンソースソフトウェアです。誰でもGitHub({github})から開発に參加したり、問題を報告したりできます。', + 'layout.auto': '自動', + 'layout.current_is': 'あなたの現在のレイアウト:', + 'layout.desktop': 'デスクトップ', + 'layout.single': 'モバイル', + 'navigation_bar.app_settings': 'アプリ設定', + 'getting_started.onboarding': '解説を表示', + 'onboarding.page_one.federation': '{domain}はMastodonのインスタンスです。Mastodonとは、独立したサーバが連携して作るソーシャルネットワークです。これらのサーバーをインスタンスと呼びます。', + 'onboarding.page_one.welcome': '{domain}へようこそ!', + 'onboarding.page_six.github': '{domain}はGlitchsocを使用しています。Glitchsocは{Mastodon}のフレンドリーな{fork}で、どんなMastodonアプリやインスタンスとも互換性があります。Glitchsocは完全に無料で、オープンソースです。{github}でバグ報告や機能要望あるいは貢獻をすることが可能です。', + 'settings.always_show_spoilers_field': '常にコンテンツワーニング設定を表示する(指定がない場合は通常投稿)', + 'settings.auto_collapse': '自動折りたたみ', + 'settings.auto_collapse_all': 'すべて', + 'settings.auto_collapse_lengthy': '長いトゥート', + 'settings.auto_collapse_media': 'メディア付きトゥート', + 'settings.auto_collapse_notifications': '通知', + 'settings.auto_collapse_reblogs': 'ブースト', + 'settings.auto_collapse_replies': '返信', + 'settings.close': '閉じる', + 'settings.collapsed_statuses': 'トゥート', + 'settings.confirm_missing_media_description': '画像に対する補助記載がないときに投稿前の警告を表示する', + 'settings.content_warnings': 'コンテンツワーニング', + 'settings.content_warnings_filter': '説明に指定した文字が含まれているものを自動で展開しないようにする', + 'settings.content_warnings.regexp': '正規表現', + 'settings.enable_collapsed': 'トゥート折りたたみを有効にする', + 'settings.enable_content_warnings_auto_unfold': 'コンテンツワーニング指定されている投稿を常に表示する', + 'settings.general': '一般', + 'settings.image_backgrounds': '画像背景', + 'settings.image_backgrounds_media': '折りたまれたメディア付きトゥートをプレビュー', + 'settings.image_backgrounds_users': '折りたまれたトゥートの背景を変更する', + 'settings.media': 'メディア', + 'settings.media_letterbox': 'メディアをレターボックス式で表示', + 'settings.media_fullwidth': '全幅メディアプレビュー', + 'settings.navbar_under': 'ナビを画面下部に移動させる(モバイル レイアウトのみ)', + 'settings.notifications.favicon_badge': '通知アイコンに未読件数を表示する', + 'settings.notifications_opts': '通知の設定', + 'settings.notifications.tab_badge': '未読の通知があるとき、通知アイコンにマークを表示する', + 'settings.preferences': 'ユーザー設定', + 'settings.wide_view': 'ワイドビュー(デスクトップ レイアウトのみ)', + 'settings.compose_box_opts': 'コンポーズボックス設定', + 'settings.show_reply_counter': '投稿に対するリプライの数を表示する', + 'settings.side_arm': 'セカンダリートゥートボタン', + 'settings.side_arm.none': '表示しない', + 'settings.side_arm_reply_mode': '返信時の投稿範囲', + 'settings.side_arm_reply_mode.copy': '返信先の投稿範囲を利用する', + 'settings.side_arm_reply_mode.keep': 'セカンダリートゥートボタンの設定を維持する', + 'settings.side_arm_reply_mode.restrict': '返信先の投稿範囲に制限する', + 'settings.layout': 'レイアウト', + 'settings.layout_opts': 'レイアウトの設定', + 'status.collapse': '折りたたむ', + 'status.uncollapse': '折りたたみを解除', + + 'confirmations.missing_media_description.message': '少なくとも1つの画像に視聴覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。', + 'confirmations.missing_media_description.confirm': 'このまま投稿', + + 'favourite_modal.combo': '次からは {combo} を押せば、これをスキップできます。', + + 'home.column_settings.show_direct': 'DMを表示', + + 'notification.markForDeletion': '選択', + 'notifications.clear': '通知を全てクリアする', + 'notifications.marked_clear_confirmation': '削除した全ての通知を完全に削除してもよろしいですか?', + 'notifications.marked_clear': '選択した通知を削除する', + + 'notification_purge.btn_all': 'すべて\n選択', + 'notification_purge.btn_none': '選択\n解除', + 'notification_purge.btn_invert': '選択を\n反転', + 'notification_purge.btn_apply': '選択したものを\n削除', + + 'compose.attach.upload': 'ファイルをアップロード', + 'compose.attach.doodle': '落書きをする', + 'compose.attach': 'アタッチ...', + + 'advanced_options.local-only.short': 'ローカル限定', + 'advanced_options.local-only.long': '他のインスタンスには投稿されません', + 'advanced_options.local-only.tooltip': 'この投稿はローカル限定投稿です', + 'advanced_options.icon_title': '高度な設定', + 'advanced_options.threaded_mode.short': 'スレッドモード', + 'advanced_options.threaded_mode.long': '投稿時に自動的に返信するように設定します', + 'advanced_options.threaded_mode.tooltip': 'スレッドモードを有効にする', + + 'navigation_bar.direct': 'ダイレクトメッセージ', + 'navigation_bar.bookmarks': 'ブックマーク', + 'column.bookmarks': 'ブックマーク' +}; + +export default Object.assign({}, inherited, messages); \ No newline at end of file diff --git a/app/javascript/flavours/glitch/locales/ko.js b/app/javascript/flavours/glitch/locales/ko.js new file mode 100644 index 000000000..3b55f89b9 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ko.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ko.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/nl.js b/app/javascript/flavours/glitch/locales/nl.js new file mode 100644 index 000000000..17c371c58 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/nl.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/nl.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/no.js b/app/javascript/flavours/glitch/locales/no.js new file mode 100644 index 000000000..794b1da25 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/no.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/no.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/oc.js b/app/javascript/flavours/glitch/locales/oc.js new file mode 100644 index 000000000..8f161fd8c --- /dev/null +++ b/app/javascript/flavours/glitch/locales/oc.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/oc.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/pl.js b/app/javascript/flavours/glitch/locales/pl.js new file mode 100644 index 000000000..f430bf577 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/pl.js @@ -0,0 +1,79 @@ +import inherited from 'mastodon/locales/pl.json'; + +const messages = { + 'getting_started.open_source_notice': 'Glitchsoc jest wolnym i otwartoźródłowym forkiem oprogramowania {Mastodon}. Możesz współtworzyć projekt lub zgłaszać błędy na GitHubie pod adresem {github}.', + 'layout.auto': 'Automatyczny', + 'layout.current_is': 'Twój obecny układ to:', + 'layout.desktop': 'Desktopowy', + 'layout.mobile': 'Mobilny', + 'navigation_bar.app_settings': 'Ustawienia aplikacji', + 'navigation_bar.bookmarks': 'Zakładki', + 'getting_started.onboarding': 'Rozejrzyj się', + 'onboarding.page_one.federation': '{domain} jest \'instancją\' Mastodona. Mastodon to sieć działających niezależnie serwerów tworzących jedną sieć społecznościową. Te serwery nazywane są instancjami.', + 'onboarding.page_one.welcome': 'Witamy na {domain}!', + 'onboarding.page_six.github': '{domain} jest oparty na Glitchsoc. Glitchsoc jest {forkiem} {Mastodon}a kompatybilnym z każdym klientem i aplikacją Mastodona. Glitchsoc jest całkowicie wolnym i otwartoźródłowym oprogramowaniem. Możesz zgłaszać błędy i sugestie funkcji oraz współtworzyć projekt na {github}.', + 'settings.auto_collapse': 'Automatyczne zwijanie', + 'settings.auto_collapse_all': 'Wszystko', + 'settings.auto_collapse_lengthy': 'Długie wpisy', + 'settings.auto_collapse_media': 'Wpisy z zawartością multimedialną', + 'settings.auto_collapse_notifications': 'Powiadomienia', + 'settings.auto_collapse_reblogs': 'Podbicia', + 'settings.auto_collapse_replies': 'Odpowiedzi', + 'settings.close': 'Zamknij', + 'settings.collapsed_statuses': 'Zwijanie wpisów', + 'settings.enable_collapsed': 'Włącz zwijanie wpisów', + 'settings.general': 'Ogólne', + 'settings.image_backgrounds': 'Obrazy w tle', + 'settings.image_backgrounds_media': 'Wyświetlaj zawartość multimedialną zwiniętych wpisów', + 'settings.image_backgrounds_users': 'Nadaj tło zwiniętym wpisom', + 'settings.layout': 'Układ', + 'settings.media': 'Zawartość multimedialna', + 'settings.media_letterbox': 'Letterbox media', + 'settings.media_fullwidth': 'Podgląd zawartości multimedialnej o pełnej szerokości', + 'settings.navbar_under': 'Pasek nawigacji na dole (tylko w trybie mobilnym)', + 'settings.preferences': 'Preferencje użytkownika', + 'settings.side_arm': 'Drugi przycisk wysyłania', + 'settings.side_arm.none': 'Żaden', + 'settings.wide_view': 'Szeroki widok (tylko w trybie desktopowym)', + 'status.bookmark': 'Dodaj do zakładek', + 'status.collapse': 'Zwiń', + 'status.uncollapse': 'Rozwiń', + + 'media_gallery.sensitive': 'Zawartość wrażliwa', + + 'favourite_modal.combo': 'Możesz nacisnąć {combo}, aby pominąć to następnym razem', + + 'home.column_settings.show_direct': 'Pokaż wiadomości bezpośrednie', + + 'notification.markForDeletion': 'Oznacz do usunięcia', + 'notifications.clear': 'Wyczyść wszystkie powiadomienia', + 'notifications.marked_clear_confirmation': 'Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?', + 'notifications.marked_clear': 'Usuń zaznaczone powiadomienia', + + 'notification_purge.btn_all': 'Zaznacz\nwszystkie', + 'notification_purge.btn_none': 'Odznacz\nwszystkie', + 'notification_purge.btn_invert': 'Odwróć\nzaznaczenie', + 'notification_purge.btn_apply': 'Usuń\nzaznaczone', + 'notification_purge.start': 'Przejdź do trybu usuwania powiadomień', + + 'compose.attach.upload': 'Wyślij plik', + 'compose.attach.doodle': 'Narysuj coś', + 'compose.attach': 'Załącz coś', + + 'advanced_options.local-only.short': 'Tylko lokalnie', + 'advanced_options.local-only.long': 'Nie wysyłaj na inne instancje', + 'advanced_options.local-only.tooltip': 'Ten wpis jest widoczny tylko lokalnie', + 'advanced_options.icon_title': 'Ustawienia zaawansowane', + 'advanced_options.threaded_mode.short': 'Tryb wątków', + 'advanced_options.threaded_mode.long': 'Przechodzi do tworzenia odpowiedzi po publikacji wpisu', + 'advanced_options.threaded_mode.tooltip': 'Włączono tryb wątków', + + 'column.bookmarks': 'Zakładki', + 'compose_form.sensitive': 'Oznacz zawartość multimedialną jako wrażliwą', + 'compose_form.spoiler': 'Ukryj tekst za ostrzeżeniem', + 'favourite_modal.combo': 'Możesz nacisnąć {combo}, aby pominąć to następnym razem', + 'tabs_bar.compose': 'Napisz', + +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/pt-BR.js b/app/javascript/flavours/glitch/locales/pt-BR.js new file mode 100644 index 000000000..6fed635f8 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/pt-BR.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/pt-BR.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/pt.js b/app/javascript/flavours/glitch/locales/pt.js new file mode 100644 index 000000000..0156f55ff --- /dev/null +++ b/app/javascript/flavours/glitch/locales/pt.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/pt.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ru.js b/app/javascript/flavours/glitch/locales/ru.js new file mode 100644 index 000000000..0e9f1de71 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ru.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ru.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sv.js b/app/javascript/flavours/glitch/locales/sv.js new file mode 100644 index 000000000..b62c353fe --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sv.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sv.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/th.js b/app/javascript/flavours/glitch/locales/th.js new file mode 100644 index 000000000..e939f8631 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/th.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/th.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/tr.js b/app/javascript/flavours/glitch/locales/tr.js new file mode 100644 index 000000000..c2b740617 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/tr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/tr.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/uk.js b/app/javascript/flavours/glitch/locales/uk.js new file mode 100644 index 000000000..ab6d9a7dc --- /dev/null +++ b/app/javascript/flavours/glitch/locales/uk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/uk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/zh-CN.js b/app/javascript/flavours/glitch/locales/zh-CN.js new file mode 100644 index 000000000..944588e02 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/zh-CN.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/zh-CN.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/zh-HK.js b/app/javascript/flavours/glitch/locales/zh-HK.js new file mode 100644 index 000000000..b71c81f2b --- /dev/null +++ b/app/javascript/flavours/glitch/locales/zh-HK.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/zh-HK.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/zh-TW.js b/app/javascript/flavours/glitch/locales/zh-TW.js new file mode 100644 index 000000000..de2b7769c --- /dev/null +++ b/app/javascript/flavours/glitch/locales/zh-TW.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/zh-TW.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/middleware/errors.js b/app/javascript/flavours/glitch/middleware/errors.js new file mode 100644 index 000000000..212c1f4ad --- /dev/null +++ b/app/javascript/flavours/glitch/middleware/errors.js @@ -0,0 +1,17 @@ +import { showAlertForError } from 'flavours/glitch/actions/alerts'; + +const defaultFailSuffix = 'FAIL'; + +export default function errorsMiddleware() { + return ({ dispatch }) => next => action => { + if (action.type && !action.skipAlert) { + const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); + + if (action.type.match(isFail)) { + dispatch(showAlertForError(action.error)); + } + } + + return next(action); + }; +}; diff --git a/app/javascript/flavours/glitch/middleware/loading_bar.js b/app/javascript/flavours/glitch/middleware/loading_bar.js new file mode 100644 index 000000000..a98f1bb2b --- /dev/null +++ b/app/javascript/flavours/glitch/middleware/loading_bar.js @@ -0,0 +1,25 @@ +import { showLoading, hideLoading } from 'react-redux-loading-bar'; + +const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED']; + +export default function loadingBarMiddleware(config = {}) { + const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes; + + return ({ dispatch }) => next => (action) => { + if (action.type && !action.skipLoading) { + const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; + + const isPending = new RegExp(`${PENDING}$`, 'g'); + const isFulfilled = new RegExp(`${FULFILLED}$`, 'g'); + const isRejected = new RegExp(`${REJECTED}$`, 'g'); + + if (action.type.match(isPending)) { + dispatch(showLoading()); + } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) { + dispatch(hideLoading()); + } + } + + return next(action); + }; +}; diff --git a/app/javascript/flavours/glitch/middleware/sounds.js b/app/javascript/flavours/glitch/middleware/sounds.js new file mode 100644 index 000000000..9f1bc02b9 --- /dev/null +++ b/app/javascript/flavours/glitch/middleware/sounds.js @@ -0,0 +1,46 @@ +const createAudio = sources => { + const audio = new Audio(); + sources.forEach(({ type, src }) => { + const source = document.createElement('source'); + source.type = type; + source.src = src; + audio.appendChild(source); + }); + return audio; +}; + +const play = audio => { + if (!audio.paused) { + audio.pause(); + if (typeof audio.fastSeek === 'function') { + audio.fastSeek(0); + } else { + audio.currentTime = 0; + } + } + + audio.play(); +}; + +export default function soundsMiddleware() { + const soundCache = { + boop: createAudio([ + { + src: '/sounds/boop.ogg', + type: 'audio/ogg', + }, + { + src: '/sounds/boop.mp3', + type: 'audio/mpeg', + }, + ]), + }; + + return () => next => action => { + if (action.meta && action.meta.sound && soundCache[action.meta.sound]) { + play(soundCache[action.meta.sound]); + } + + return next(action); + }; +}; diff --git a/app/javascript/flavours/glitch/names.yml b/app/javascript/flavours/glitch/names.yml new file mode 100644 index 000000000..0801c4565 --- /dev/null +++ b/app/javascript/flavours/glitch/names.yml @@ -0,0 +1,15 @@ +en: + flavours: + glitch: + description: The default flavour for GlitchSoc instances. + name: Glitch Edition + skins: + glitch: + default: Default +pl: + flavours: + glitch: + description: Domyślny motyw instancji GlitchSoc. + skins: + glitch: + default: Domyślny diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js new file mode 100644 index 000000000..bc0a4887b --- /dev/null +++ b/app/javascript/flavours/glitch/packs/about.js @@ -0,0 +1,22 @@ +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; + +function loaded() { + const TimelineContainer = require('flavours/glitch/containers/timeline_container').default; + const React = require('react'); + const ReactDOM = require('react-dom'); + const mountNode = document.getElementById('mastodon-timeline'); + + if (mountNode !== null) { + const props = JSON.parse(mountNode.getAttribute('data-props')); + ReactDOM.render(<TimelineContainer {...props} />, mountNode); + } +} + +function main() { + const ready = require('flavours/glitch/util/ready').default; + ready(loaded); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js new file mode 100644 index 000000000..94a4e6ee4 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/common.js @@ -0,0 +1,8 @@ +import { start } from 'rails-ujs'; + +start(); + +import 'flavours/glitch/styles/index.scss'; + +// This ensures that webpack compiles our images. +require.context('../images', true); diff --git a/app/javascript/flavours/glitch/packs/error.js b/app/javascript/flavours/glitch/packs/error.js new file mode 100644 index 000000000..81c86c3ab --- /dev/null +++ b/app/javascript/flavours/glitch/packs/error.js @@ -0,0 +1,13 @@ +import ready from 'flavours/glitch/util/ready'; + +ready(() => { + const image = document.querySelector('img'); + + image.addEventListener('mouseenter', () => { + image.src = '/oops.gif'; + }); + + image.addEventListener('mouseleave', () => { + image.src = '/oops.png'; + }); +}); diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js new file mode 100644 index 000000000..b8f7b7d8e --- /dev/null +++ b/app/javascript/flavours/glitch/packs/home.js @@ -0,0 +1,7 @@ +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; + +loadPolyfills().then(() => { + require('flavours/glitch/util/main').default(); +}).catch(e => { + console.error(e); +}); diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js new file mode 100644 index 000000000..da0b4c8e0 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/public.js @@ -0,0 +1,102 @@ +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; +import ready from 'flavours/glitch/util/ready'; + +function main() { + const IntlMessageFormat = require('intl-messageformat').default; + const { timeAgoString } = require('flavours/glitch/components/relative_timestamp'); + const emojify = require('flavours/glitch/util/emoji').default; + const { getLocale } = require('locales'); + const { messages } = getLocale(); + const React = require('react'); + const ReactDOM = require('react-dom'); + const Rellax = require('rellax'); + const createHistory = require('history').createBrowserHistory; + + const scrollToDetailedStatus = () => { + const history = createHistory(); + const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status'); + const location = history.location; + + if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) { + detailedStatuses[0].scrollIntoView(); + history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true }); + } + }; + + ready(() => { + const locale = document.documentElement.lang; + + const dateTimeFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); + + [].forEach.call(document.querySelectorAll('.emojify'), (content) => { + content.innerHTML = emojify(content.innerHTML); + }); + + [].forEach.call(document.querySelectorAll('time.formatted'), (content) => { + const datetime = new Date(content.getAttribute('datetime')); + const formattedDate = dateTimeFormat.format(datetime); + + content.title = formattedDate; + content.textContent = formattedDate; + }); + + [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => { + const datetime = new Date(content.getAttribute('datetime')); + const now = new Date(); + + content.title = dateTimeFormat.format(datetime); + content.textContent = timeAgoString({ + formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values), + formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date), + }, datetime, now, now.getFullYear()); + }); + + const reactComponents = document.querySelectorAll('[data-component]'); + if (reactComponents.length > 0) { + import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container') + .then(({ default: MediaContainer }) => { + [].forEach.call(reactComponents, (component) => { + [].forEach.call(component.children, (child) => { + component.removeChild(child); + }); + }); + + const content = document.createElement('div'); + + ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content); + document.body.appendChild(content); + scrollToDetailedStatus(); + }) + .catch(error => { + console.error(error); + scrollToDetailedStatus(); + }); + } else { + scrollToDetailedStatus(); + } + + const parallaxComponents = document.querySelectorAll('.parallax'); + + if (parallaxComponents.length > 0 ) { + new Rellax('.parallax', { speed: -1 }); + } + + if (document.body.classList.contains('with-modals')) { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + const scrollbarWidthStyle = document.createElement('style'); + scrollbarWidthStyle.id = 'scrollbar-width'; + document.head.appendChild(scrollbarWidthStyle); + scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0); + } + }); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js new file mode 100644 index 000000000..9f2aa2553 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/share.js @@ -0,0 +1,22 @@ +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; + +function loaded() { + const ComposeContainer = require('flavours/glitch/containers/compose_container').default; + const React = require('react'); + const ReactDOM = require('react-dom'); + const mountNode = document.getElementById('mastodon-compose'); + + if (mountNode !== null) { + const props = JSON.parse(mountNode.getAttribute('data-props')); + ReactDOM.render(<ComposeContainer {...props} />, mountNode); + } +} + +function main() { + const ready = require('flavours/glitch/util/ready').default; + ready(loaded); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js new file mode 100644 index 000000000..530ed8e60 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/accounts.js @@ -0,0 +1,33 @@ +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const normalizeAccount = (state, account) => { + account = { ...account }; + + delete account.followers_count; + delete account.following_count; + delete account.statuses_count; + + return state.set(account.id, fromJS(account)); +}; + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +export default function accounts(state = initialState, action) { + switch(action.type) { + case ACCOUNT_IMPORT: + return normalizeAccount(state, action.account); + case ACCOUNTS_IMPORT: + return normalizeAccounts(state, action.accounts); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/accounts_counters.js b/app/javascript/flavours/glitch/reducers/accounts_counters.js new file mode 100644 index 000000000..9ebf72af9 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js @@ -0,0 +1,38 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from '../actions/accounts'; +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const normalizeAccount = (state, account) => state.set(account.id, fromJS({ + followers_count: account.followers_count, + following_count: account.following_count, + statuses_count: account.statuses_count, +})); + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const initialState = ImmutableMap(); + +export default function accountsCounters(state = initialState, action) { + switch(action.type) { + case ACCOUNT_IMPORT: + return normalizeAccount(state, action.account); + case ACCOUNTS_IMPORT: + return normalizeAccounts(state, action.accounts); + case ACCOUNT_FOLLOW_SUCCESS: + return action.alreadyFollowing ? state : + 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)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js new file mode 100644 index 000000000..50f8d30f7 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/alerts.js @@ -0,0 +1,25 @@ +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR, +} from 'flavours/glitch/actions/alerts'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableList([]); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return state.push(ImmutableMap({ + key: state.size > 0 ? state.last().get('key') + 1 : 0, + title: action.title, + message: action.message, + })); + case ALERT_DISMISS: + return state.filterNot(item => item.get('key') === action.alert.key); + case ALERT_CLEAR: + return state.clear(); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js new file mode 100644 index 000000000..009e1fee7 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -0,0 +1,468 @@ +import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_CHANGE, + COMPOSE_CYCLE_ELEFRIEND, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_DIRECT, + COMPOSE_MENTION, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_UNDO, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, + COMPOSE_ADVANCED_OPTIONS_CHANGE, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_DOODLE_SET, + COMPOSE_RESET, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, +} from 'flavours/glitch/actions/compose'; +import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { REDRAFT } from 'flavours/glitch/actions/statuses'; +import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; +import uuid from 'flavours/glitch/util/uuid'; +import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; +import { me } from 'flavours/glitch/util/initial_state'; +import { overwrite } from 'flavours/glitch/util/js_helpers'; +import { unescapeHTML } from 'flavours/glitch/util/html'; +import { recoverHashtags } from 'flavours/glitch/util/hashtag'; + +const totalElefriends = 3; + +// ~4% chance you'll end up with an unexpected friend +// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z +const glitchProbability = 1 - 0.0420215528; + +const initialState = ImmutableMap({ + mounted: false, + advanced_options: ImmutableMap({ + do_not_federate: false, + threaded_mode: false, + }), + sensitive: false, + elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends, + spoiler: false, + spoiler_text: '', + privacy: null, + text: '', + focusDate: null, + caretPosition: null, + preselectDate: null, + in_reply_to: null, + is_submitting: false, + is_uploading: false, + is_changing_upload: false, + progress: 0, + media_attachments: ImmutableList(), + poll: null, + suggestion_token: null, + suggestions: ImmutableList(), + default_advanced_options: ImmutableMap({ + do_not_federate: false, + threaded_mode: null, // Do not reset + }), + default_privacy: 'public', + default_sensitive: false, + resetFileKey: Math.floor((Math.random() * 0x10000)), + idempotencyKey: null, + tagHistory: ImmutableList(), + 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, + }), +}); + +const initialPoll = ImmutableMap({ + options: ImmutableList(['', '']), + expires_in: 24 * 3600, + multiple: false, +}); + +function statusToTextMentions(state, status) { + let set = ImmutableOrderedSet([]); + + if (status.getIn(['account', 'id']) !== me) { + set = set.add(`@${status.getIn(['account', 'acct'])} `); + } + + return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); +}; + +function apiStatusToTextMentions (state, status) { + let set = ImmutableOrderedSet([]); + + if (status.account.id !== me) { + set = set.add(`@${status.account.acct} `); + } + + return set.union(status.mentions.filter( + mention => mention.id !== me + ).map( + mention => `@${mention.acct} ` + )).join(''); +} + +function apiStatusToTextHashtags (state, status) { + const text = unescapeHTML(status.content); + return ImmutableOrderedSet([]).union(recoverHashtags(status.tags, text).map( + (name) => `#${name} ` + )).join(''); +} + +function clearAll(state) { + return state.withMutations(map => { + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('is_submitting', false); + map.set('is_changing_upload', false); + map.set('in_reply_to', null); + map.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')) + ); + map.set('privacy', state.get('default_privacy')); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('poll', null); + map.set('idempotencyKey', uuid()); + }); +}; + +function continueThread (state, status) { + return state.withMutations(function (map) { + let text = apiStatusToTextMentions(state, status); + text = text + apiStatusToTextHashtags(state, status); + map.set('text', text); + if (status.spoiler_text) { + map.set('spoiler', true); + map.set('spoiler_text', status.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + map.set('is_submitting', false); + map.set('in_reply_to', status.id); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) })) + ); + map.set('privacy', status.visibility); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + }); +} + +function appendMedia(state, media) { + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.push(media)); + map.set('is_uploading', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); + map.set('idempotencyKey', uuid()); + + if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { + map.set('sensitive', true); + } + }); +}; + +function removeMedia(state, mediaId) { + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId)); + map.set('idempotencyKey', uuid()); + + if (prevSize === 1) { + map.set('sensitive', false); + } + }); +}; + +const insertSuggestion = (state, position, token, completion, path) => { + return state.withMutations(map => { + map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.set('suggestions', ImmutableList()); + if (path.length === 1 && path[0] === 'text') { + map.set('focusDate', new Date()); + map.set('caretPosition', position + completion.length + 1); + } + map.set('idempotencyKey', uuid()); + }); +}; + +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + return state.merge({ + suggestions: state.get('tagHistory') + .filter(tag => tag.toLowerCase().startsWith(prefix.toLowerCase())) + .slice(0, 4) + .map(tag => '#' + tag), + suggestion_token: token, + }); +}; + +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.native; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`); + map.set('focusDate', new Date()); + map.set('caretPosition', position + emoji.length + 1); + map.set('idempotencyKey', uuid()); + }); +}; + +const hydrate = (state, hydratedState) => { + state = clearAll(state.merge(hydratedState)); + + if (hydratedState.has('text')) { + state = state.set('text', hydratedState.get('text')); + } + + return state; +}; + +const domParser = new DOMParser(); + +const expandMentions = status => { + const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement; + + status.get('mentions').forEach(mention => { + fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`; + }); + + return fragment.innerHTML; +}; + +export default function compose(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('compose')); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); + case COMPOSE_ADVANCED_OPTIONS_CHANGE: + return state + .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value))) + .set('idempotencyKey', uuid()); + case COMPOSE_SENSITIVITY_CHANGE: + return state.withMutations(map => { + if (!state.get('spoiler')) { + map.set('sensitive', !state.get('sensitive')); + } + + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SPOILERNESS_CHANGE: + return state.withMutations(map => { + map.set('spoiler_text', ''); + map.set('spoiler', !state.get('spoiler')); + map.set('idempotencyKey', uuid()); + + if (!state.get('sensitive') && state.get('media_attachments').size >= 1) { + map.set('sensitive', true); + } + }); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state + .set('spoiler_text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_VISIBILITY_CHANGE: + return state + .set('privacy', action.value) + .set('idempotencyKey', uuid()); + case COMPOSE_CHANGE: + return state + .set('text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_CYCLE_ELEFRIEND: + return state + .set('elefriend', (state.get('elefriend') + 1) % totalElefriends); + case COMPOSE_REPLY: + return state.withMutations(map => { + 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.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) })) + ); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + let spoiler_text = action.status.get('spoiler_text'); + if (!spoiler_text.match(/^re[: ]/i)) { + spoiler_text = 're: '.concat(spoiler_text); + } + map.set('spoiler', true); + map.set('spoiler_text', spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_REPLY_CANCEL: + state = state.setIn(['advanced_options', 'threaded_mode'], false); + case COMPOSE_RESET: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('privacy', state.get('default_privacy')); + map.set('poll', null); + map.update( + 'advanced_options', + map => map.mergeWith(overwrite, state.get('default_advanced_options')) + ); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUBMIT_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_UPLOAD_CHANGE_REQUEST: + return state.set('is_changing_upload', true); + case COMPOSE_SUBMIT_SUCCESS: + return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state); + case COMPOSE_SUBMIT_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_CHANGE_FAIL: + return state.set('is_changing_upload', false); + case COMPOSE_UPLOAD_REQUEST: + return state.set('is_uploading', true); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, fromJS(action.media)); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case COMPOSE_MENTION: + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_DIRECT: + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('privacy', 'direct'); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion, action.path); + case COMPOSE_SUGGESTION_TAGS_UPDATE: + return updateSuggestionTags(state, action.token); + case COMPOSE_TAG_HISTORY_UPDATE: + return state.set('tagHistory', fromJS(action.tags)); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else { + return state; + } + case COMPOSE_EMOJI_INSERT: + return insertEmoji(state, action.position, action.emoji); + case COMPOSE_UPLOAD_CHANGE_SUCCESS: + return state + .set('is_changing_upload', false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return fromJS(action.media); + } + + return item; + })); + case COMPOSE_DOODLE_SET: + return state.mergeIn(['doodle'], action.options); + case REDRAFT: + return state.withMutations(map => { + map.set('text', unescapeHTML(expandMentions(action.status))); + map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('privacy', action.status.get('visibility')); + map.set('media_attachments', action.status.get('media_attachments')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: 24 * 3600, + })); + } + }); + case COMPOSE_POLL_ADD: + return state.set('poll', initialPoll); + case COMPOSE_POLL_REMOVE: + return state.set('poll', null); + case COMPOSE_POLL_OPTION_ADD: + return state.updateIn(['poll', 'options'], options => options.push(action.title)); + case COMPOSE_POLL_OPTION_CHANGE: + return state.setIn(['poll', 'options', action.index], action.title); + case COMPOSE_POLL_OPTION_REMOVE: + return state.updateIn(['poll', 'options'], options => options.delete(action.index)); + case COMPOSE_POLL_SETTINGS_CHANGE: + return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js new file mode 100644 index 000000000..73b25fe3f --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/contexts.js @@ -0,0 +1,105 @@ +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { CONTEXT_FETCH_SUCCESS } from 'flavours/glitch/actions/statuses'; +import { TIMELINE_DELETE, TIMELINE_UPDATE } from 'flavours/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; + +const initialState = ImmutableMap({ + inReplyTos: ImmutableMap(), + replies: ImmutableMap(), +}); + +const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => { + state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { + state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { + function addReply({ id, in_reply_to_id }) { + if (in_reply_to_id && !inReplyTos.has(id)) { + + replies.update(in_reply_to_id, ImmutableList(), siblings => { + const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0); + return siblings.insert(index + 1, id); + }); + + inReplyTos.set(id, in_reply_to_id); + } + } + + // We know in_reply_to_id of statuses but `id` itself. + // So we assume that the status of the id replies to last ancestors. + + ancestors.forEach(addReply); + + if (ancestors[0]) { + addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id }); + } + + descendants.forEach(addReply); + })); + })); +}); + +const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => { + state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { + state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { + ids.forEach(id => { + const inReplyToIdOfId = inReplyTos.get(id); + const repliesOfId = replies.get(id); + const siblings = replies.get(inReplyToIdOfId); + + if (siblings) { + replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id)); + } + + + if (repliesOfId) { + repliesOfId.forEach(reply => inReplyTos.delete(reply)); + } + + inReplyTos.delete(id); + replies.delete(id); + }); + })); + })); +}); + +const filterContexts = (state, relationship, statuses) => { + const ownedStatusIds = statuses.filter(status => status.get('account') === relationship.id) + .map(status => status.get('id')); + + return deleteFromContexts(state, ownedStatusIds); +}; + +const updateContext = (state, status) => { + if (status.in_reply_to_id) { + return state.withMutations(mutable => { + const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList()); + + mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id); + + if (!replies.includes(status.id)) { + mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id)); + } + }); + } + + return state; +}; + +export default function replies(state = initialState, action) { + switch(action.type) { + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterContexts(state, action.relationship, action.statuses); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, action.ancestors, action.descendants); + case TIMELINE_DELETE: + return deleteFromContexts(state, [action.id]); + case TIMELINE_UPDATE: + return updateContext(state, action.status); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/custom_emojis.js b/app/javascript/flavours/glitch/reducers/custom_emojis.js new file mode 100644 index 000000000..90e3040a4 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/custom_emojis.js @@ -0,0 +1,15 @@ +import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable'; +import { CUSTOM_EMOJIS_FETCH_SUCCESS } from 'flavours/glitch/actions/custom_emojis'; +import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; +import { buildCustomEmojis } from 'flavours/glitch/util/emoji'; + +const initialState = ImmutableList([]); + +export default function custom_emojis(state = initialState, action) { + if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) { + state = ConvertToImmutable(action.custom_emojis); + emojiSearch('', { custom: buildCustomEmojis(state) }); + } + + return state; +}; diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js new file mode 100644 index 000000000..eff97fbd6 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/domain_lists.js @@ -0,0 +1,25 @@ +import { + DOMAIN_BLOCKS_FETCH_SUCCESS, + DOMAIN_BLOCKS_EXPAND_SUCCESS, + DOMAIN_UNBLOCK_SUCCESS, +} from '../actions/domain_blocks'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +const initialState = ImmutableMap({ + blocks: ImmutableMap({ + items: ImmutableOrderedSet(), + }), +}); + +export default function domainLists(state = initialState, action) { + switch(action.type) { + case DOMAIN_BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next); + case DOMAIN_BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next); + case DOMAIN_UNBLOCK_SUCCESS: + return state.updateIn(['blocks', 'items'], set => set.delete(action.domain)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/dropdown_menu.js b/app/javascript/flavours/glitch/reducers/dropdown_menu.js new file mode 100644 index 000000000..36fd4f132 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/dropdown_menu.js @@ -0,0 +1,18 @@ +import Immutable from 'immutable'; +import { + DROPDOWN_MENU_OPEN, + DROPDOWN_MENU_CLOSE, +} from '../actions/dropdown_menu'; + +const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false }); + +export default function dropdownMenu(state = initialState, action) { + switch (action.type) { + case DROPDOWN_MENU_OPEN: + return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard }); + case DROPDOWN_MENU_CLOSE: + return state.get('openId') === action.id ? state.set('openId', null) : state; + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/filters.js b/app/javascript/flavours/glitch/reducers/filters.js new file mode 100644 index 000000000..33f0c6732 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/filters.js @@ -0,0 +1,11 @@ +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; +import { List as ImmutableList, fromJS } from 'immutable'; + +export default function filters(state = ImmutableList(), action) { + switch(action.type) { + case FILTERS_FETCH_SUCCESS: + return fromJS(action.filters); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/height_cache.js b/app/javascript/flavours/glitch/reducers/height_cache.js new file mode 100644 index 000000000..8b05e0b19 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/height_cache.js @@ -0,0 +1,23 @@ +import { Map as ImmutableMap } from 'immutable'; +import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from 'flavours/glitch/actions/height_cache'; + +const initialState = ImmutableMap(); + +const setHeight = (state, key, id, height) => { + return state.update(key, ImmutableMap(), map => map.set(id, height)); +}; + +const clearHeights = () => { + return ImmutableMap(); +}; + +export default function statuses(state = initialState, action) { + switch(action.type) { + case HEIGHT_CACHE_SET: + return setHeight(state, action.key, action.id, action.height); + case HEIGHT_CACHE_CLEAR: + return clearHeights(); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/identity_proofs.js b/app/javascript/flavours/glitch/reducers/identity_proofs.js new file mode 100644 index 000000000..58af0a5fa --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/identity_proofs.js @@ -0,0 +1,25 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; +import { + IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, + IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, + IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, +} from '../actions/identity_proofs'; + +const initialState = ImmutableMap(); + +export default function identityProofsReducer(state = initialState, action) { + switch(action.type) { + case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST: + return state.set('isLoading', true); + case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL: + return state.set('isLoading', false); + case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS: + return state.update(identity_proofs => identity_proofs.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set(action.accountId, fromJS(action.identity_proofs)); + })); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js new file mode 100644 index 000000000..45b93b92c --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -0,0 +1,72 @@ +import { combineReducers } from 'redux-immutable'; +import dropdown_menu from './dropdown_menu'; +import timelines from './timelines'; +import meta from './meta'; +import alerts from './alerts'; +import { loadingBarReducer } from 'react-redux-loading-bar'; +import modal from './modal'; +import user_lists from './user_lists'; +import domain_lists from './domain_lists'; +import accounts from './accounts'; +import accounts_counters from './accounts_counters'; +import statuses from './statuses'; +import relationships from './relationships'; +import settings from './settings'; +import local_settings from './local_settings'; +import push_notifications from './push_notifications'; +import status_lists from './status_lists'; +import mutes from './mutes'; +import reports from './reports'; +import contexts from './contexts'; +import compose from './compose'; +import search from './search'; +import media_attachments from './media_attachments'; +import notifications from './notifications'; +import height_cache from './height_cache'; +import custom_emojis from './custom_emojis'; +import lists from './lists'; +import listEditor from './list_editor'; +import listAdder from './list_adder'; +import filters from './filters'; +import suggestions from './suggestions'; +import pinnedAccountsEditor from './pinned_accounts_editor'; +import polls from './polls'; +import identity_proofs from './identity_proofs'; + +const reducers = { + dropdown_menu, + timelines, + meta, + alerts, + loadingBar: loadingBarReducer, + modal, + user_lists, + domain_lists, + status_lists, + accounts, + accounts_counters, + statuses, + relationships, + settings, + local_settings, + push_notifications, + mutes, + reports, + contexts, + compose, + search, + media_attachments, + notifications, + height_cache, + custom_emojis, + identity_proofs, + lists, + listEditor, + listAdder, + filters, + suggestions, + pinnedAccountsEditor, + polls, +}; + +export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/list_adder.js b/app/javascript/flavours/glitch/reducers/list_adder.js new file mode 100644 index 000000000..b8c1b0e26 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/list_adder.js @@ -0,0 +1,47 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + LIST_ADDER_RESET, + LIST_ADDER_SETUP, + LIST_ADDER_LISTS_FETCH_REQUEST, + LIST_ADDER_LISTS_FETCH_SUCCESS, + LIST_ADDER_LISTS_FETCH_FAIL, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + accountId: null, + + lists: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function listAdderReducer(state = initialState, action) { + switch(action.type) { + case LIST_ADDER_RESET: + return initialState; + case LIST_ADDER_SETUP: + return state.withMutations(map => { + map.set('accountId', action.account.get('id')); + }); + case LIST_ADDER_LISTS_FETCH_REQUEST: + return state.setIn(['lists', 'isLoading'], true); + case LIST_ADDER_LISTS_FETCH_FAIL: + return state.setIn(['lists', 'isLoading'], false); + case LIST_ADDER_LISTS_FETCH_SUCCESS: + return state.update('lists', lists => lists.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.lists.map(item => item.id))); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['lists', 'items'], list => list.unshift(action.listId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/list_editor.js b/app/javascript/flavours/glitch/reducers/list_editor.js new file mode 100644 index 000000000..5427ac098 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/list_editor.js @@ -0,0 +1,96 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + LIST_CREATE_REQUEST, + LIST_CREATE_FAIL, + LIST_CREATE_SUCCESS, + LIST_UPDATE_REQUEST, + LIST_UPDATE_FAIL, + LIST_UPDATE_SUCCESS, + LIST_EDITOR_RESET, + LIST_EDITOR_SETUP, + LIST_EDITOR_TITLE_CHANGE, + LIST_ACCOUNTS_FETCH_REQUEST, + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_ACCOUNTS_FETCH_FAIL, + LIST_EDITOR_SUGGESTIONS_READY, + LIST_EDITOR_SUGGESTIONS_CLEAR, + LIST_EDITOR_SUGGESTIONS_CHANGE, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + listId: null, + isSubmitting: false, + isChanged: false, + title: '', + + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case LIST_EDITOR_RESET: + return initialState; + case LIST_EDITOR_SETUP: + return state.withMutations(map => { + map.set('listId', action.list.get('id')); + map.set('title', action.list.get('title')); + map.set('isSubmitting', false); + }); + case LIST_EDITOR_TITLE_CHANGE: + return state.withMutations(map => { + map.set('title', action.value); + map.set('isChanged', true); + }); + case LIST_CREATE_REQUEST: + case LIST_UPDATE_REQUEST: + return state.withMutations(map => { + map.set('isSubmitting', true); + map.set('isChanged', false); + }); + case LIST_CREATE_FAIL: + case LIST_UPDATE_FAIL: + return state.set('isSubmitting', false); + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return state.withMutations(map => { + map.set('isSubmitting', false); + map.set('listId', action.list.id); + }); + case LIST_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case LIST_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case LIST_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case LIST_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case LIST_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case LIST_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/lists.js b/app/javascript/flavours/glitch/reducers/lists.js new file mode 100644 index 000000000..f30ffbcbd --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/lists.js @@ -0,0 +1,37 @@ +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_CREATE_SUCCESS, + LIST_UPDATE_SUCCESS, + LIST_DELETE_SUCCESS, +} from '../actions/lists'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const normalizeList = (state, list) => state.set(list.id, fromJS(list)); + +const normalizeLists = (state, lists) => { + lists.forEach(list => { + state = normalizeList(state, list); + }); + + return state; +}; + +export default function lists(state = initialState, action) { + switch(action.type) { + case LIST_FETCH_SUCCESS: + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return normalizeList(state, action.list); + case LISTS_FETCH_SUCCESS: + return normalizeLists(state, action.lists); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js new file mode 100644 index 000000000..8dea4d8f6 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -0,0 +1,64 @@ +// Package imports. +import { Map as ImmutableMap } from 'immutable'; + +// Our imports. +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { LOCAL_SETTING_CHANGE } from 'flavours/glitch/actions/local_settings'; + +const initialState = ImmutableMap({ + layout : 'auto', + stretch : true, + navbar_under : false, + swipe_to_change_columns: true, + side_arm : 'none', + side_arm_reply_mode : 'keep', + show_reply_count : false, + always_show_spoilers_field: false, + confirm_missing_media_description: false, + confirm_before_clearing_draft: true, + preselect_on_reply: true, + inline_preview_cards: true, + hicolor_privacy_icons: false, + content_warnings : ImmutableMap({ + auto_unfold : false, + filter : null, + }), + 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, + }), + show_action_bar : true, + }), + media : ImmutableMap({ + letterbox : true, + fullwidth : true, + reveal_behind_cw : false, + }), + notifications : ImmutableMap({ + favicon_badge : false, + tab_badge : true, + }), +}); + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +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/flavours/glitch/reducers/media_attachments.js b/app/javascript/flavours/glitch/reducers/media_attachments.js new file mode 100644 index 000000000..6e6058576 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/media_attachments.js @@ -0,0 +1,15 @@ +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { Map as ImmutableMap } from 'immutable'; + +const initialState = ImmutableMap({ + accept_content_types: [], +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('media_attachments')); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/meta.js b/app/javascript/flavours/glitch/reducers/meta.js new file mode 100644 index 000000000..a98dc436a --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/meta.js @@ -0,0 +1,16 @@ +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { Map as ImmutableMap } from 'immutable'; + +const initialState = ImmutableMap({ + streaming_api_base_url: null, + access_token: null, +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('meta')); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js new file mode 100644 index 000000000..80bc11dda --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -0,0 +1,17 @@ +import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal'; + +const initialState = { + modalType: null, + modalProps: {}, +}; + +export default function modal(state = initialState, action) { + switch(action.type) { + case MODAL_OPEN: + return { modalType: action.modalType, modalProps: action.modalProps }; + case MODAL_CLOSE: + return initialState; + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js new file mode 100644 index 000000000..8f52a7704 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -0,0 +1,29 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, +} from 'flavours/glitch/actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account: null, + notifications: true, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'isSubmitting'], false); + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js new file mode 100644 index 000000000..04ae3d406 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -0,0 +1,249 @@ +import { + NOTIFICATIONS_MOUNT, + NOTIFICATIONS_UNMOUNT, + NOTIFICATIONS_SET_VISIBILITY, + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_FILTER_SET, + 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 'flavours/glitch/actions/notifications'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + hasMore: true, + top: true, + mounted: 0, + unread: 0, + lastReadId: '0', + isLoading: false, + cleaningMode: false, + isTabVisible: true, + // notification removal mark of new notifs loaded whilst cleaningMode is true. + markNewForDelete: false, +}); + +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, +}); + +const normalizeNotification = (state, notification) => { + const top = !shouldCountUnreadNotifications(state); + + if (top) { + state = state.set('lastReadId', notification.id); + } else { + state = state.update('unread', unread => unread + 1); + } + + return state.update('items', list => { + if (top && list.size > 40) { + list = list.take(20); + } + + return list.unshift(notificationToMap(state, notification)); + }); +}; + +const expandNormalizedNotifications = (state, notifications, next) => { + const top = !(shouldCountUnreadNotifications(state)); + const lastReadId = state.get('lastReadId'); + let items = ImmutableList(); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(state, n)); + }); + + return state.withMutations(mutable => { + if (!items.isEmpty()) { + mutable.update('items', list => { + const lastIndex = 1 + list.findLastIndex( + item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')) + ); + + const firstIndex = 1 + list.take(lastIndex).findLastIndex( + item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0 + ); + + return list.take(firstIndex).concat(items, list.skip(lastIndex)); + }); + } + + if (top) { + if (!items.isEmpty()) { + mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id')); + } + } else { + mutable.update('unread', unread => unread + items.filter(item => compareId(item.get('id'), lastReadId) > 0).size); + } + + if (!next) { + mutable.set('hasMore', false); + } + + mutable.set('isLoading', false); + }); +}; + +const filterNotifications = (state, relationship) => { + return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); +}; + +const clearUnread = (state) => { + state = state.set('unread', 0); + const lastNotification = state.get('items').find(item => item !== null); + return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0'); +} + +const updateTop = (state, top) => { + state = state.set('top', top); + + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + + return state.set('top', top); +}; + +const deleteByStatus = (state, statusId) => { + const top = !(shouldCountUnreadNotifications(state)); + if (!top) { + const lastReadId = state.get('lastReadId'); + const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); + state = state.update('unread', unread => unread - deletedUnread.size); + } + return state.update('items', list => list.filterNot(item => item !== null && 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'))); +}; + +const updateMounted = (state) => { + state = state.update('mounted', count => count + 1); + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + return state; +}; + +const updateVisibility = (state, visibility) => { + state = state.set('isTabVisible', visibility); + if (!shouldCountUnreadNotifications(state)) { + state = clearUnread(state); + } + return state; +}; + +const shouldCountUnreadNotifications = (state) => { + return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0); +}; + +export default function notifications(state = initialState, action) { + let st; + + switch(action.type) { + case NOTIFICATIONS_MOUNT: + return updateMounted(state); + case NOTIFICATIONS_UNMOUNT: + return state.update('mounted', count => count - 1); + case NOTIFICATIONS_SET_VISIBILITY: + return updateVisibility(state, action.visibility); + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_DELETE_MARKED_REQUEST: + return state.set('isLoading', true); + case NOTIFICATIONS_DELETE_MARKED_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', false); + case NOTIFICATIONS_FILTER_SET: + return state.set('items', ImmutableList()).set('hasMore', true); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_EXPAND_SUCCESS: + return expandNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, action.relationship); + case ACCOUNT_MUTE_SUCCESS: + return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; + case NOTIFICATIONS_CLEAR: + return state.set('items', ImmutableList()).set('hasMore', false); + case TIMELINE_DELETE: + return deleteByStatus(state, action.id); + case TIMELINE_DISCONNECT: + return action.timeline === 'home' ? + state.update('items', items => items.first() ? items.unshift(null) : items) : + state; + + 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/flavours/glitch/reducers/pinned_accounts_editor.js b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js new file mode 100644 index 000000000..267521bb8 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js @@ -0,0 +1,57 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + PINNED_ACCOUNTS_EDITOR_RESET, + PINNED_ACCOUNTS_FETCH_REQUEST, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_FETCH_FAIL, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, + PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_UNPIN_SUCCESS, +} from '../actions/accounts'; + +const initialState = ImmutableMap({ + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case PINNED_ACCOUNTS_EDITOR_RESET: + return initialState; + case PINNED_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case PINNED_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case PINNED_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case ACCOUNT_PIN_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id)); + case ACCOUNT_UNPIN_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js new file mode 100644 index 000000000..9956cf83f --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/polls.js @@ -0,0 +1,15 @@ +import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); + +const initialState = ImmutableMap(); + +export default function polls(state = initialState, action) { + switch(action.type) { + case POLLS_IMPORT: + return importPolls(state, action.polls); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js new file mode 100644 index 000000000..e87e8fc1a --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/push_notifications.js @@ -0,0 +1,52 @@ +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from 'flavours/glitch/actions/push_notifications'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + favourite: false, + reblog: false, + mention: false, + poll: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case SET_ALERTS: + return state.setIn(action.path, action.value); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js new file mode 100644 index 000000000..4652bbc14 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/relationships.js @@ -0,0 +1,58 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_UNPIN_SUCCESS, + RELATIONSHIPS_FETCH_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { + DOMAIN_BLOCK_SUCCESS, + DOMAIN_UNBLOCK_SUCCESS, +} from 'flavours/glitch/actions/domain_blocks'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship)); + +const normalizeRelationships = (state, relationships) => { + relationships.forEach(relationship => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const setDomainBlocking = (state, accounts, blocking) => { + return state.withMutations(map => { + accounts.forEach(id => { + map.setIn([id, 'domain_blocking'], blocking); + }); + }); +}; + +const initialState = ImmutableMap(); + +export default function relationships(state = initialState, action) { + switch(action.type) { + case ACCOUNT_FOLLOW_SUCCESS: + case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_UNBLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + case ACCOUNT_UNMUTE_SUCCESS: + case ACCOUNT_PIN_SUCCESS: + case ACCOUNT_UNPIN_SUCCESS: + return normalizeRelationship(state, action.relationship); + case RELATIONSHIPS_FETCH_SUCCESS: + return normalizeRelationships(state, action.relationships); + case DOMAIN_BLOCK_SUCCESS: + return setDomainBlocking(state, action.accounts, true); + case DOMAIN_UNBLOCK_SUCCESS: + return setDomainBlocking(state, action.accounts, false); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js new file mode 100644 index 000000000..fdcfb14a0 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/reports.js @@ -0,0 +1,64 @@ +import { + REPORT_INIT, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_CANCEL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE, + REPORT_FORWARD_CHANGE, +} from 'flavours/glitch/actions/reports'; +import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; + +const initialState = ImmutableMap({ + new: ImmutableMap({ + isSubmitting: false, + account_id: null, + status_ids: ImmutableSet(), + comment: '', + forward: false, + }), +}); + +export default function reports(state = initialState, action) { + switch(action.type) { + case REPORT_INIT: + return state.withMutations(map => { + map.setIn(['new', 'isSubmitting'], false); + map.setIn(['new', 'account_id'], action.account.get('id')); + + if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { + map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet()); + map.setIn(['new', 'comment'], ''); + } else if (action.status) { + map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); + } + }); + case REPORT_STATUS_TOGGLE: + return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => { + if (action.checked) { + return set.add(action.statusId); + } + + return set.remove(action.statusId); + }); + case REPORT_COMMENT_CHANGE: + return state.setIn(['new', 'comment'], action.comment); + case REPORT_FORWARD_CHANGE: + return state.setIn(['new', 'forward'], action.forward); + case REPORT_SUBMIT_REQUEST: + return state.setIn(['new', 'isSubmitting'], true); + case REPORT_SUBMIT_FAIL: + return state.setIn(['new', 'isSubmitting'], false); + case REPORT_CANCEL: + case REPORT_SUBMIT_SUCCESS: + return state.withMutations(map => { + map.setIn(['new', 'account_id'], null); + map.setIn(['new', 'status_ids'], ImmutableSet()); + map.setIn(['new', 'comment'], ''); + map.setIn(['new', 'isSubmitting'], false); + }); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js new file mode 100644 index 000000000..9a525bf47 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/search.js @@ -0,0 +1,47 @@ +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW, +} from 'flavours/glitch/actions/search'; +import { + COMPOSE_MENTION, + COMPOSE_REPLY, + COMPOSE_DIRECT, +} from 'flavours/glitch/actions/compose'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + value: '', + submitted: false, + hidden: false, + results: ImmutableMap(), +}); + +export default function search(state = initialState, action) { + switch(action.type) { + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_CLEAR: + return state.withMutations(map => { + map.set('value', ''); + map.set('results', ImmutableMap()); + map.set('submitted', false); + map.set('hidden', false); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + case COMPOSE_DIRECT: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', ImmutableMap({ + accounts: ImmutableList(action.results.accounts.map(item => item.id)), + statuses: ImmutableList(action.results.statuses.map(item => item.id)), + hashtags: fromJS(action.results.hashtags), + })).set('submitted', true); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js new file mode 100644 index 000000000..cc86a6b20 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -0,0 +1,151 @@ +import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings'; +import { NOTIFICATIONS_FILTER_SET } from 'flavours/glitch/actions/notifications'; +import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from 'flavours/glitch/actions/columns'; +import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; +import { EMOJI_USE } from 'flavours/glitch/actions/emojis'; +import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists'; +import { Map as ImmutableMap, fromJS } from 'immutable'; +import uuid from 'flavours/glitch/util/uuid'; + +const initialState = ImmutableMap({ + saved: true, + + onboarded: false, + layout: 'auto', + + skinTone: 1, + + home: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + direct: true, + }), + + regex: ImmutableMap({ + body: '', + }), + }), + + notifications: ImmutableMap({ + alerts: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + poll: true, + }), + + quickFilter: ImmutableMap({ + active: 'all', + show: true, + advanced: false, + }), + + shows: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + poll: true, + }), + + sounds: ImmutableMap({ + follow: true, + favourite: true, + reblog: true, + mention: true, + poll: true, + }), + }), + + community: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + public: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), +}); + +const defaultColumns = fromJS([ + { id: 'COMPOSE', uuid: uuid(), params: {} }, + { id: 'HOME', uuid: uuid(), params: {} }, + { id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, +]); + +const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val); + +const moveColumn = (state, uuid, direction) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + const newIndex = index + direction; + + let newColumns; + + newColumns = columns.splice(index, 1); + newColumns = newColumns.splice(newIndex, 0, columns.get(index)); + + return state + .set('columns', newColumns) + .set('saved', false); +}; + +const changeColumnParams = (state, uuid, path, value) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + + const newColumns = columns.update(index, column => column.updateIn(['params', ...path], () => value)); + + return state + .set('columns', newColumns) + .set('saved', false); +}; + +const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false); + +const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('settings')); + case NOTIFICATIONS_FILTER_SET: + case SETTING_CHANGE: + return state + .setIn(action.path, action.value) + .set('saved', false); + case COLUMN_ADD: + return state + .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params }))) + .set('saved', false); + case COLUMN_REMOVE: + return state + .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)) + .set('saved', false); + case COLUMN_MOVE: + return moveColumn(state, action.uuid, action.direction); + case COLUMN_PARAMS_CHANGE: + return changeColumnParams(state, action.uuid, action.path, action.value); + case EMOJI_USE: + return updateFrequentEmojis(state, action.emoji); + case SETTING_SAVE: + return state.set('saved', true); + case LIST_FETCH_FAIL: + return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state; + case LIST_DELETE_SUCCESS: + return filterDeadListColumns(state, action.id); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js new file mode 100644 index 000000000..241833bfe --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/status_lists.js @@ -0,0 +1,116 @@ +import { + FAVOURITED_STATUSES_FETCH_REQUEST, + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_FETCH_FAIL, + FAVOURITED_STATUSES_EXPAND_REQUEST, + FAVOURITED_STATUSES_EXPAND_SUCCESS, + FAVOURITED_STATUSES_EXPAND_FAIL, +} from 'flavours/glitch/actions/favourites'; +import { + BOOKMARKED_STATUSES_FETCH_REQUEST, + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_FETCH_FAIL, + BOOKMARKED_STATUSES_EXPAND_REQUEST, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_FAIL, +} from 'flavours/glitch/actions/bookmarks'; +import { + PINNED_STATUSES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/pin_statuses'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + BOOKMARK_SUCCESS, + UNBOOKMARK_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, +} from 'flavours/glitch/actions/interactions'; + +const initialState = ImmutableMap({ + favourites: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), + bookmarks: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), + pins: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('isLoading', false); + map.set('items', ImmutableList(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('isLoading', false); + map.set('items', map.get('items').concat(statuses.map(item => item.id))); + })); +}; + +const prependOneToList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').unshift(status.get('id'))); + })); +}; + +const removeOneFromList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').filter(item => item !== status.get('id'))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_REQUEST: + case FAVOURITED_STATUSES_EXPAND_REQUEST: + return state.setIn(['favourites', 'isLoading'], true); + case FAVOURITED_STATUSES_FETCH_FAIL: + case FAVOURITED_STATUSES_EXPAND_FAIL: + return state.setIn(['favourites', 'isLoading'], false); + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + case BOOKMARKED_STATUSES_FETCH_REQUEST: + case BOOKMARKED_STATUSES_EXPAND_REQUEST: + return state.setIn(['bookmarks', 'isLoading'], true); + case BOOKMARKED_STATUSES_FETCH_FAIL: + case BOOKMARKED_STATUSES_EXPAND_FAIL: + return state.setIn(['bookmarks', 'isLoading'], false); + case BOOKMARKED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'bookmarks', action.statuses, action.next); + case BOOKMARKED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'bookmarks', action.statuses, action.next); + case FAVOURITE_SUCCESS: + return prependOneToList(state, 'favourites', action.status); + case UNFAVOURITE_SUCCESS: + return removeOneFromList(state, 'favourites', action.status); + case BOOKMARK_SUCCESS: + return prependOneToList(state, 'bookmarks', action.status); + case UNBOOKMARK_SUCCESS: + return removeOneFromList(state, 'bookmarks', action.status); + case PINNED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'pins', action.statuses, action.next); + case PIN_SUCCESS: + return prependOneToList(state, 'pins', action.status); + case UNPIN_SUCCESS: + return removeOneFromList(state, 'pins', action.status); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js new file mode 100644 index 000000000..96c9c6d04 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -0,0 +1,61 @@ +import { + REBLOG_REQUEST, + REBLOG_FAIL, + FAVOURITE_REQUEST, + FAVOURITE_FAIL, + BOOKMARK_REQUEST, + BOOKMARK_FAIL, +} from 'flavours/glitch/actions/interactions'; +import { + STATUS_MUTE_SUCCESS, + STATUS_UNMUTE_SUCCESS, +} from 'flavours/glitch/actions/statuses'; +import { + TIMELINE_DELETE, +} from 'flavours/glitch/actions/timelines'; +import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const importStatus = (state, status) => state.set(status.id, fromJS(status)); + +const importStatuses = (state, statuses) => + state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); + +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref[0], []); + }); + + return state.delete(id); +}; + +const initialState = ImmutableMap(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case STATUS_IMPORT: + return importStatus(state, action.status); + case STATUSES_IMPORT: + return importStatuses(state, action.statuses); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); + case BOOKMARK_REQUEST: + return state.setIn([action.status.get('id'), 'bookmarked'], true); + case BOOKMARK_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false); + case STATUS_MUTE_SUCCESS: + return state.setIn([action.id, 'muted'], true); + case STATUS_UNMUTE_SUCCESS: + return state.setIn([action.id, 'muted'], false); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js new file mode 100644 index 000000000..9f4b89d58 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/suggestions.js @@ -0,0 +1,30 @@ +import { + SUGGESTIONS_FETCH_REQUEST, + SUGGESTIONS_FETCH_SUCCESS, + SUGGESTIONS_FETCH_FAIL, + SUGGESTIONS_DISMISS, +} from '../actions/suggestions'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, +}); + +export default function suggestionsReducer(state = initialState, action) { + switch(action.type) { + case SUGGESTIONS_FETCH_REQUEST: + return state.set('isLoading', true); + case SUGGESTIONS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.accounts.map(x => x.id))); + map.set('isLoading', false); + }); + case SUGGESTIONS_FETCH_FAIL: + return state.set('isLoading', false); + case SUGGESTIONS_DISMISS: + return state.update('items', list => list.filterNot(id => id === action.id)); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js new file mode 100644 index 000000000..cb233de1c --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -0,0 +1,154 @@ +import { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_CLEAR, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, +} from 'flavours/glitch/actions/timelines'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; + +const initialState = ImmutableMap(); + +const initialTimeline = ImmutableMap({ + unread: 0, + online: false, + top: true, + isLoading: false, + hasMore: true, + items: ImmutableList(), +}); + +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + mMap.set('isLoading', false); + mMap.set('isPartial', isPartial); + + if (!next && !isLoadingRecent) mMap.set('hasMore', false); + + if (!statuses.isEmpty()) { + mMap.update('items', ImmutableList(), oldIds => { + const newIds = statuses.map(status => status.get('id')); + const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; + const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0); + + if (firstIndex < 0) { + return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex)); + } + + return oldIds.take(firstIndex + 1).concat( + isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds, + oldIds.skip(lastIndex) + ); + }); + } + })); +}; + +const updateTimeline = (state, timeline, status) => { + const top = state.getIn([timeline, 'top']); + const ids = state.getIn([timeline, 'items'], ImmutableList()); + const includesId = ids.includes(status.get('id')); + const unread = state.getIn([timeline, 'unread'], 0); + + if (includesId) { + return state; + } + + let newIds = ids; + + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (!top) mMap.set('unread', unread + 1); + if (top && ids.size > 40) newIds = newIds.take(20); + mMap.set('items', newIds.unshift(status.get('id'))); + })); +}; + +const deleteStatus = (state, id, accountId, references, exclude_account = null) => { + state.keySeq().forEach(timeline => { + if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + }); + + // Remove reblogs of deleted status + references.forEach(ref => { + state = deleteStatus(state, ref[0], ref[1], [], exclude_account); + }); + + return state; +}; + +const clearTimeline = (state, timeline) => { + return state.set(timeline, initialTimeline); +}; + +const filterTimelines = (state, relationship, statuses) => { + let references; + + statuses.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]); + state = deleteStatus(state, status.get('id'), status.get('account'), references, relationship.id); + }); + + return state; +}; + +const filterTimeline = (timeline, state, relationship, statuses) => + state.updateIn([timeline, 'items'], ImmutableList(), list => + list.filterNot(statusId => + statuses.getIn([statusId, 'account']) === relationship.id + )); + +const updateTop = (state, timeline, top) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (top) mMap.set('unread', 0); + mMap.set('top', top); + })); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_EXPAND_REQUEST: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); + case TIMELINE_EXPAND_FAIL: + return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); + case TIMELINE_EXPAND_SUCCESS: + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, fromJS(action.status)); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case TIMELINE_CLEAR: + return clearTimeline(state, action.timeline); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case ACCOUNT_UNFOLLOW_SUCCESS: + return filterTimeline('home', state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.update(action.timeline, initialTimeline, map => map.set('online', true)); + case TIMELINE_DISCONNECT: + return state.update( + action.timeline, + initialTimeline, + map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) + ); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js new file mode 100644 index 000000000..a4df9ec8d --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -0,0 +1,80 @@ +import { + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_REJECT_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS, +} from 'flavours/glitch/actions/interactions'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS, +} from 'flavours/glitch/actions/mutes'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialState = ImmutableMap({ + followers: ImmutableMap(), + following: ImmutableMap(), + reblogged_by: ImmutableMap(), + favourited_by: ImmutableMap(), + follow_requests: ImmutableMap(), + blocks: ImmutableMap(), + mutes: ImmutableMap(), +}); + +const normalizeList = (state, type, id, accounts, next) => { + return state.setIn([type, id], ImmutableMap({ + next, + items: ImmutableList(accounts.map(item => item.id)), + })); +}; + +const appendToList = (state, type, id, accounts, next) => { + return state.updateIn([type, id], map => { + return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id))); + }); +}; + +export default function userLists(state = initialState, action) { + switch(action.type) { + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + case BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case MUTES_FETCH_SUCCESS: + return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case MUTES_EXPAND_SUCCESS: + return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js new file mode 100644 index 000000000..d1a88a2fc --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -0,0 +1,144 @@ +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; +import { me } from 'flavours/glitch/util/initial_state'; + +const getAccountBase = (state, id) => state.getIn(['accounts', id], null); +const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); +const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); +const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]); + +export const makeGetAccount = () => { + return createSelector([getAccountBase, getAccountCounters, getAccountRelationship, getAccountMoved], (base, counters, relationship, moved) => { + if (base === null) { + return null; + } + + return base.merge(counters).withMutations(map => { + map.set('relationship', relationship); + map.set('moved', moved); + }); + }); +}; + +const toServerSideType = columnType => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; + +export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); + +const escapeRegExp = string => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +export const regexFromFilters = filters => { + if (filters.size === 0) { + return null; + } + + return new RegExp(filters.map(filter => { + let expr = escapeRegExp(filter.get('phrase')); + + if (filter.get('whole_word')) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + return expr; + }).join('|'), 'i'); +}; + +export const makeGetStatus = () => { + return createSelector( + [ + (state, { id }) => state.getIn(['statuses', id]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + getFilters, + ], + + (statusBase, statusReblog, accountBase, accountReblog, filters) => { + if (!statusBase) { + return null; + } + + const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters); + let filtered = false; + + if (statusReblog) { + filtered = regex && regex.test(statusReblog.get('search_index')); + statusReblog = statusReblog.set('account', accountReblog); + statusReblog = statusReblog.set('filtered', filtered); + } else { + statusReblog = null; + } + + filtered = filtered || regex && regex.test(statusBase.get('search_index')); + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + map.set('filtered', filtered); + }); + } + ); +}; + +const getAlertsBase = state => state.get('alerts'); + +export const getAlerts = createSelector([getAlertsBase], (base) => { + let arr = []; + + base.forEach(item => { + arr.push({ + message: item.get('message'), + title: item.get('title'), + key: item.get('key'), + dismissAfter: 5000, + barStyle: { + zIndex: 200, + }, + }); + }); + + return arr; +}); + +export const makeGetNotification = () => { + return createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), + ], (base, account) => { + return base.set('account', account); + }); +}; + +export const getAccountGallery = createSelector([ + (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, statuses) => { + let medias = ImmutableList(); + + statusIds.forEach(statusId => { + const status = statuses.get(statusId); + medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status))); + }); + + return medias; +}); diff --git a/app/javascript/flavours/glitch/store/configureStore.js b/app/javascript/flavours/glitch/store/configureStore.js new file mode 100644 index 000000000..7e7472841 --- /dev/null +++ b/app/javascript/flavours/glitch/store/configureStore.js @@ -0,0 +1,15 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import appReducer from '../reducers'; +import loadingBarMiddleware from '../middleware/loading_bar'; +import errorsMiddleware from '../middleware/errors'; +import soundsMiddleware from '../middleware/sounds'; + +export default function configureStore() { + return createStore(appReducer, compose(applyMiddleware( + thunk, + loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), + errorsMiddleware(), + soundsMiddleware() + ), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f)); +}; diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss new file mode 100644 index 000000000..d542b1083 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/_mixins.scss @@ -0,0 +1,114 @@ +@mixin avatar-radius() { + border-radius: $ui-avatar-border-size; + background-position: 50%; + background-clip: padding-box; +} + +@mixin avatar-size($size:48px) { + width: $size; + 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; + } +} + +@mixin fullwidth-gallery { + &.full-width { + margin-left: -14px; + margin-right: -14px; + width: inherit; + max-width: none; + height: 250px; + border-radius: 0px; + } +} + +@mixin search-input() { + outline: 0; + box-sizing: border-box; + width: 100%; + border: none; + box-shadow: none; + font-family: inherit; + background: $ui-base-color; + color: $darker-text-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } +} + +@mixin search-popout() { + background: $simple-background-color; + border-radius: 4px; + padding: 10px 14px; + padding-bottom: 14px; + margin-top: 10px; + color: $light-text-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + h4 { + text-transform: uppercase; + color: $light-text-color; + font-size: 13px; + font-weight: 500; + margin-bottom: 10px; + } + + li { + padding: 4px 0; + } + + ul { + margin-bottom: 10px; + } + + em { + font-weight: 500; + color: $inverted-text-color; + } +} diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss new file mode 100644 index 000000000..d4ead07a1 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/about.scss @@ -0,0 +1,858 @@ +$maximum-width: 1235px; +$fluid-breakpoint: $maximum-width + 20px; +$column-breakpoint: 700px; +$small-breakpoint: 960px; + +.container { + box-sizing: border-box; + max-width: $maximum-width; + margin: 0 auto; + position: relative; + + @media screen and (max-width: $fluid-breakpoint) { + width: 100%; + padding: 0 10px; + } +} + +.rich-formatting { + font-family: $font-sans-serif, sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $darker-text-color; + padding-right: 10px; + + a { + color: $highlight-text-color; + text-decoration: underline; + } + + p, + li { + font-family: $font-sans-serif, sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + margin-bottom: 12px; + color: $darker-text-color; + + a { + color: $highlight-text-color; + text-decoration: underline; + } + + &:last-child { + margin-bottom: 0; + } + } + + strong, + em { + font-weight: 700; + color: lighten($darker-text-color, 10%); + } + + h1 { + font-family: $font-display, sans-serif; + font-size: 26px; + line-height: 30px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + + small { + font-family: $font-sans-serif, sans-serif; + display: block; + font-size: 18px; + font-weight: 400; + color: lighten($darker-text-color, 10%); + } + } + + h2 { + font-family: $font-display, sans-serif; + font-size: 22px; + line-height: 26px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h3 { + font-family: $font-display, sans-serif; + font-size: 18px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h4 { + font-family: $font-display, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h5 { + font-family: $font-display, sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h6 { + font-family: $font-display, sans-serif; + font-size: 12px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + ul, + ol { + margin-left: 20px; + + &[type='a'] { + list-style-type: lower-alpha; + } + + &[type='i'] { + list-style-type: lower-roman; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li > ol, + li > ul { + margin-top: 6px; + } + + hr { + width: 100%; + height: 0; + border: 0; + border-bottom: 1px solid rgba($ui-base-lighter-color, .6); + margin: 20px 0; + + &.spacer { + height: 1px; + border: 0; + } + } +} + +.information-board { + background: darken($ui-base-color, 4%); + padding: 20px 0; + + .container-alt { + position: relative; + padding-right: 280px + 15px; + } + + &__sections { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } + + &__section { + flex: 1 0 0; + font-family: $font-sans-serif, sans-serif; + font-size: 16px; + line-height: 28px; + color: $primary-text-color; + text-align: right; + padding: 10px 15px; + + span, + strong { + display: block; + } + + span { + &:last-child { + color: $secondary-text-color; + } + } + + strong { + font-family: $font-display, sans-serif; + font-weight: 500; + font-size: 32px; + line-height: 48px; + } + + @media screen and (max-width: $column-breakpoint) { + text-align: center; + } + } + + .panel { + position: absolute; + width: 280px; + box-sizing: border-box; + background: darken($ui-base-color, 8%); + padding: 20px; + padding-top: 10px; + border-radius: 4px 4px 0 0; + right: 0; + bottom: -40px; + + .panel-header { + font-family: $font-display, sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: $darker-text-color; + padding-bottom: 5px; + margin-bottom: 15px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + a, + span { + font-weight: 400; + color: darken($darker-text-color, 10%); + } + + a { + text-decoration: none; + } + } + } + + .owner { + text-align: center; + + .avatar { + width: 80px; + height: 80px; + @include avatar-size(80px); + margin: 0 auto; + margin-bottom: 15px; + + img { + display: block; + width: 80px; + height: 80px; + border-radius: 48px; + @include avatar-radius(); + } + } + + .name { + font-size: 14px; + + a { + display: block; + color: $primary-text-color; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + + .username { + display: block; + color: $darker-text-color; + } + } + } +} + +.landing-page { + p, + li { + font-family: $font-sans-serif, sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + margin-bottom: 12px; + color: $darker-text-color; + + a { + color: $highlight-text-color; + text-decoration: underline; + } + } + + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 700; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($darker-text-color, 10%); + } + + h1 { + font-family: $font-display, sans-serif; + font-size: 26px; + line-height: 30px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + + small { + font-family: $font-sans-serif, sans-serif; + display: block; + font-size: 18px; + font-weight: 400; + color: lighten($darker-text-color, 10%); + } + } + + h2 { + font-family: $font-display, sans-serif; + font-size: 22px; + line-height: 26px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h3 { + font-family: $font-display, sans-serif; + font-size: 18px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h4 { + font-family: $font-display, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h5 { + font-family: $font-display, sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + h6 { + font-family: $font-display, sans-serif; + font-size: 12px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $secondary-text-color; + } + + ul, + ol { + margin-left: 20px; + + &[type='a'] { + list-style-type: lower-alpha; + } + + &[type='i'] { + list-style-type: lower-roman; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li > ol, + li > ul { + margin-top: 6px; + } + + hr { + width: 100%; + height: 0; + border: 0; + border-bottom: 1px solid rgba($ui-base-lighter-color, .6); + margin: 20px 0; + + &.spacer { + height: 1px; + border: 0; + } + } + + &__information, + &__forms { + padding: 20px; + } + + &__call-to-action { + background: darken($ui-base-color, 4%); + border-radius: 4px; + padding: 25px 40px; + overflow: hidden; + box-sizing: border-box; + + .row { + width: 100%; + display: flex; + flex-direction: row-reverse; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; + } + + .row__information-board { + display: flex; + justify-content: flex-end; + align-items: flex-end; + + .information-board__section { + flex: 1 0 auto; + padding: 0 10px; + } + + @media screen and (max-width: $no-gap-breakpoint) { + width: 100%; + justify-content: space-between; + } + } + + .row__mascot { + flex: 1; + margin: 10px -50px 0 0; + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } + } + } + + &__logo { + margin-right: 20px; + + img { + height: 50px; + width: auto; + mix-blend-mode: lighten; + } + } + + &__information { + padding: 45px 40px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + color: lighten($darker-text-color, 10%); + } + + .account { + border-bottom: 0; + padding: 0; + + &__display-name { + align-items: center; + display: flex; + margin-right: 5px; + } + + div.account__display-name { + &:hover { + .display-name strong { + text-decoration: none; + } + } + + .account__avatar { + cursor: default; + } + } + + &__avatar-wrapper { + margin-left: 0; + flex: 0 0 auto; + } + + &__avatar { + width: 44px; + height: 44px; + background-size: 44px 44px; + @include avatar-size(44px); + } + + .display-name { + font-size: 15px; + + &__account { + font-size: 14px; + } + } + } + + @media screen and (max-width: $small-breakpoint) { + .contact { + margin-top: 30px; + } + } + + @media screen and (max-width: $column-breakpoint) { + padding: 25px 20px; + } + } + + &__information, + &__forms, + #mastodon-timeline { + box-sizing: border-box; + background: $ui-base-color; + border-radius: 4px; + box-shadow: 0 0 6px rgba($black, 0.1); + } + + &__mascot { + height: 104px; + position: relative; + left: -40px; + bottom: 25px; + + img { + height: 190px; + width: auto; + } + } + + &__short-description { + .row { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 40px; + } + + @media screen and (max-width: $column-breakpoint) { + .row { + margin-bottom: 20px; + } + } + + p a { + color: $secondary-text-color; + } + + h1 { + font-weight: 500; + color: $primary-text-color; + margin-bottom: 0; + + small { + color: $darker-text-color; + + span { + color: $secondary-text-color; + } + } + } + + p:last-child { + margin-bottom: 0; + } + } + + &__hero { + margin-bottom: 10px; + + img { + display: block; + margin: 0; + max-width: 100%; + height: auto; + border-radius: 4px; + } + } + + @media screen and (max-width: 840px) { + .information-board { + .container-alt { + padding-right: 20px; + } + + .panel { + position: static; + margin-top: 20px; + width: 100%; + border-radius: 4px; + + .panel-header { + text-align: center; + } + } + } + } + + @media screen and (max-width: 675px) { + .header-wrapper { + padding-top: 0; + + &.compact { + padding-bottom: 0; + } + + &.compact .hero .heading { + text-align: initial; + } + } + + .header .container-alt, + .features .container-alt { + display: block; + } + } + + .cta { + margin: 20px; + } +} + +.landing { + margin-bottom: 100px; + + @media screen and (max-width: 738px) { + margin-bottom: 0; + } + + &__brand { + display: flex; + justify-content: center; + align-items: center; + padding: 50px; + + img { + height: 52px; + } + + @media screen and (max-width: $no-gap-breakpoint) { + padding: 0; + margin-bottom: 30px; + } + } + + .directory { + margin-top: 30px; + background: transparent; + box-shadow: none; + border-radius: 0; + } + + .hero-widget { + margin-top: 30px; + margin-bottom: 0; + + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; + } + + &__text { + border-radius: 0; + padding-bottom: 0; + } + + &__footer { + background: $ui-base-color; + padding: 10px; + border-radius: 0 0 4px 4px; + display: flex; + + &__column { + flex: 1 1 50%; + } + } + + .account { + padding: 10px 0; + border-bottom: 0; + + .account__display-name { + display: flex; + align-items: center; + } + + .account__avatar { + width: 44px; + height: 44px; + background-size: 44px 44px; + } + } + + &__counter { + padding: 10px; + + strong { + font-family: $font-display, sans-serif; + font-size: 15px; + font-weight: 700; + display: block; + } + + span { + font-size: 14px; + color: $darker-text-color; + } + } + } + + .simple_form .user_agreement .label_input > label { + font-weight: 400; + color: $darker-text-color; + } + + .simple_form p.lead { + color: $darker-text-color; + font-size: 15px; + line-height: 20px; + font-weight: 400; + margin-bottom: 25px; + } + + &__grid { + max-width: 960px; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + grid-gap: 30px; + + @media screen and (max-width: 738px) { + grid-template-columns: minmax(0, 100%); + grid-gap: 10px; + + &__column-login { + grid-row: 1; + display: flex; + flex-direction: column; + + .box-widget { + order: 2; + flex: 0 0 auto; + } + + .hero-widget { + margin-top: 0; + margin-bottom: 10px; + order: 1; + flex: 0 0 auto; + } + } + + &__column-registration { + grid-row: 2; + } + + .directory { + margin-top: 10px; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + grid-gap: 0; + + .hero-widget { + display: block; + margin-bottom: 0; + box-shadow: none; + + &__img, + &__img img, + &__footer { + border-radius: 0; + } + } + + .hero-widget, + .box-widget, + .directory__tag { + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + .directory { + margin-top: 0; + + &__tag { + margin-bottom: 0; + + & > a, + & > div { + border-radius: 0; + box-shadow: none; + } + + &:last-child { + border-bottom: 0; + } + } + } + } + } +} + +.brand { + position: relative; + text-decoration: none; +} + +.brand__tagline { + display: block; + position: absolute; + bottom: -10px; + left: 50px; + width: 300px; + color: $ui-primary-color; + text-decoration: none; + font-size: 14px; + + @media screen and (max-width: $no-gap-breakpoint) { + position: static; + width: auto; + margin-top: 20px; + color: $dark-text-color; + } +} + diff --git a/app/javascript/flavours/glitch/styles/accessibility.scss b/app/javascript/flavours/glitch/styles/accessibility.scss new file mode 100644 index 000000000..35e91da80 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/accessibility.scss @@ -0,0 +1,35 @@ +$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default; + +%emoji-outline { + filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color); +} + +.emojione { + @each $emoji in $emojis-requiring-outlines { + &[title=':#{$emoji}:'] { + @extend %emoji-outline; + } + } +} + +.hicolor-privacy-icons { + .status__visibility-icon.fa-globe, + .composer--options--dropdown--content--item .fa-globe { + color: #1976D2; + } + + .status__visibility-icon.fa-unlock, + .composer--options--dropdown--content--item .fa-unlock { + color: #388E3C; + } + + .status__visibility-icon.fa-lock, + .composer--options--dropdown--content--item .fa-lock { + color: #FFA000; + } + + .status__visibility-icon.fa-envelope, + .composer--options--dropdown--content--item .fa-envelope { + color: #D32F2F; + } +} diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss new file mode 100644 index 000000000..57451c3a1 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/accounts.scss @@ -0,0 +1,322 @@ +.card { + & > a { + display: block; + text-decoration: none; + color: inherit; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + @media screen and (max-width: $no-gap-breakpoint) { + box-shadow: none; + } + + &:hover, + &:active, + &:focus { + .card__bar { + background: lighten($ui-base-color, 8%); + } + } + } + + &__img { + height: 130px; + position: relative; + background: darken($ui-base-color, 12%); + border-radius: 4px 4px 0 0; + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + border-radius: 4px 4px 0 0; + } + + @media screen and (max-width: 600px) { + height: 200px; + } + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } + } + + &__bar { + position: relative; + padding: 15px; + display: flex; + justify-content: flex-start; + align-items: center; + background: lighten($ui-base-color, 4%); + border-radius: 0 0 4px 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-radius: 0; + } + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + @include avatar-size(48px); + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + @include avatar-radius(); + background: darken($ui-base-color, 8%); + } + } + + .display-name { + margin-left: 15px; + text-align: left; + + strong { + font-size: 15px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} + +.pagination { + padding: 30px 0; + text-align: center; + overflow: hidden; + + a, + .current, + .newer, + .older, + .page, + .gap { + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + display: inline-block; + padding: 6px 10px; + text-decoration: none; + } + + .current { + background: $simple-background-color; + border-radius: 100px; + color: $inverted-text-color; + cursor: default; + margin: 0 10px; + } + + .gap { + cursor: default; + } + + .older, + .newer { + text-transform: uppercase; + color: $secondary-text-color; + } + + .older { + float: left; + padding-left: 0; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + .newer { + float: right; + padding-right: 0; + + .fa { + display: inline-block; + margin-left: 5px; + } + } + + .disabled { + cursor: default; + color: lighten($inverted-text-color, 10%); + } + + @media screen and (max-width: 700px) { + padding: 30px 20px; + + .page { + display: none; + } + + .newer, + .older { + display: inline-block; + } + } +} + +.nothing-here { + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + color: $light-text-color; + font-size: 14px; + font-weight: 500; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + cursor: default; + border-radius: 4px; + padding: 20px; + min-height: 30vh; + + &--under-tabs { + border-radius: 0 0 4px 4px; + } + + &--flexible { + box-sizing: border-box; + min-height: 100%; + } +} + +.account-role { + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: $ui-secondary-color; + background-color: rgba($ui-secondary-color, 0.1); + border: 1px solid rgba($ui-secondary-color, 0.5); + + &.moderator { + color: $success-green; + background-color: rgba($success-green, 0.1); + border-color: rgba($success-green, 0.5); + } + + &.admin { + color: lighten($error-red, 12%); + background-color: rgba(lighten($error-red, 12%), 0.1); + border-color: rgba(lighten($error-red, 12%), 0.5); + } +} + +.account__header__fields { + padding: 0; + margin: 15px -15px -15px; + border: 0 none; + border-top: 1px solid lighten($ui-base-color, 12%); + border-bottom: 1px solid lighten($ui-base-color, 12%); + font-size: 14px; + line-height: 20px; + + dl { + display: flex; + border-bottom: 1px solid lighten($ui-base-color, 12%); + } + + dt, + dd { + box-sizing: border-box; + padding: 14px; + text-align: center; + max-height: 48px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + dt { + font-weight: 500; + width: 120px; + flex: 0 0 auto; + color: $secondary-text-color; + background: rgba(darken($ui-base-color, 8%), 0.5); + } + + dd { + flex: 1 1 auto; + color: $darker-text-color; + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + .verified { + border: 1px solid rgba($valid-value-color, 0.5); + background: rgba($valid-value-color, 0.25); + + a { + color: $valid-value-color; + font-weight: 500; + } + + &__mark { + color: $valid-value-color; + } + } + + dl:last-child { + border-bottom: 0; + } +} + +.directory__tag .trends__item__current { + width: auto; +} + +.pending-account { + &__header { + color: $darker-text-color; + + a { + color: $ui-secondary-color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + + strong { + color: $primary-text-color; + font-weight: 700; + } + } + + &__body { + margin-top: 10px; + } +} diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss new file mode 100644 index 000000000..74f91599a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -0,0 +1,722 @@ +$no-columns-breakpoint: 600px; +$sidebar-width: 240px; +$content-width: 840px; + +.admin-wrapper { + display: flex; + justify-content: center; + height: 100%; + + .sidebar-wrapper { + flex: 1 1 $sidebar-width; + height: 100%; + background: $ui-base-color; + display: flex; + justify-content: flex-end; + } + + .sidebar { + width: $sidebar-width; + height: 100%; + padding: 0; + overflow-y: auto; + + .logo { + display: block; + margin: 40px auto; + width: 100px; + height: 100px; + } + + @media screen and (max-width: $no-columns-breakpoint) { + & > a:first-child { + display: none; + } + } + + ul { + list-style: none; + border-radius: 4px 0 0 4px; + overflow: hidden; + margin-bottom: 20px; + + @media screen and (max-width: $no-columns-breakpoint) { + margin-bottom: 0; + } + + a { + display: block; + padding: 15px; + color: $darker-text-color; + text-decoration: none; + transition: all 200ms linear; + transition-property: color, background-color; + border-radius: 4px 0 0 4px; + + i.fa { + margin-right: 5px; + } + + &:hover { + color: $primary-text-color; + background-color: darken($ui-base-color, 5%); + transition: all 100ms linear; + transition-property: color, background-color; + } + + &.selected { + background: darken($ui-base-color, 2%); + border-radius: 4px 0 0; + } + } + + ul { + background: darken($ui-base-color, 4%); + border-radius: 0 0 0 4px; + margin: 0; + + a { + border: 0; + padding: 15px 35px; + } + } + + .simple-navigation-active-leaf a { + color: $primary-text-color; + background-color: $ui-highlight-color; + border-bottom: 0; + border-radius: 0; + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + } + } + + & > ul > .simple-navigation-active-leaf a { + border-radius: 4px 0 0 4px; + } + } + + .content-wrapper { + flex: 2 1 $content-width; + overflow: auto; + } + + .content { + max-width: $content-width; + padding: 20px 15px; + padding-top: 60px; + padding-left: 25px; + + @media screen and (max-width: $no-columns-breakpoint) { + max-width: none; + padding: 15px; + padding-top: 30px; + } + + h2 { + color: $secondary-text-color; + font-size: 24px; + line-height: 28px; + font-weight: 400; + padding-bottom: 40px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + margin-bottom: 40px; + } + + h3 { + color: $secondary-text-color; + font-size: 20px; + line-height: 28px; + font-weight: 400; + margin-bottom: 30px; + } + + h4 { + text-transform: uppercase; + font-size: 13px; + font-weight: 700; + color: $darker-text-color; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + h6 { + font-size: 16px; + color: $secondary-text-color; + line-height: 28px; + font-weight: 400; + } + + .fields-group h6 { + color: $primary-text-color; + font-weight: 500; + } + + .directory__tag > a, + .directory__tag > div { + box-shadow: none; + } + + .directory__tag .table-action-link .fa { + color: inherit; + } + + .directory__tag h4 { + font-size: 18px; + font-weight: 700; + color: $primary-text-color; + text-transform: none; + padding-bottom: 0; + margin-bottom: 0; + border-bottom: none; + } + + & > p { + font-size: 14px; + line-height: 18px; + color: $secondary-text-color; + margin-bottom: 20px; + + strong { + color: $primary-text-color; + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + } + + hr { + width: 100%; + height: 0; + border: 0; + border-bottom: 1px solid rgba($ui-base-lighter-color, .6); + margin: 20px 0; + + &.spacer { + height: 1px; + border: 0; + } + } + + .muted-hint { + color: $darker-text-color; + + a { + color: $highlight-text-color; + } + } + + .positive-hint { + color: $valid-value-color; + font-weight: 500; + } + + .negative-hint { + color: $error-value-color; + font-weight: 500; + } + + .neutral-hint { + color: $dark-text-color; + font-weight: 500; + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + .sidebar-wrapper, + .content-wrapper { + flex: 0 0 auto; + height: auto; + overflow: initial; + } + + .sidebar { + width: 100%; + padding: 0; + height: auto; + } + } +} + +.filters { + display: flex; + flex-wrap: wrap; + + .filter-subset { + flex: 0 0 auto; + margin: 0 40px 10px 0; + + &:last-child { + margin-bottom: 20px; + } + + ul { + margin-top: 5px; + list-style: none; + + li { + display: inline-block; + margin-right: 5px; + } + } + + strong { + font-weight: 500; + text-transform: uppercase; + font-size: 12px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + border-bottom: 2px solid $ui-base-color; + + &:hover { + color: $primary-text-color; + border-bottom: 2px solid lighten($ui-base-color, 5%); + } + + &.selected { + color: $highlight-text-color; + border-bottom: 2px solid $ui-highlight-color; + } + } + } +} + +.flavour-screen { + display: block; + margin: 10px auto; + max-width: 100%; +} + +.flavour-description { + display: block; + font-size: 16px; + margin: 10px 0; + + & > p { + margin: 10px 0; + } +} + +.report-accounts { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.report-accounts__item { + display: flex; + flex: 250px; + flex-direction: column; + margin: 0 5px; + + & > strong { + display: block; + margin: 0 0 10px -5px; + font-weight: 500; + font-size: 14px; + line-height: 18px; + color: $secondary-text-color; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + .account-card { + flex: 1 1 auto; + } +} + +.report-status, +.account-status { + display: flex; + margin-bottom: 10px; + + .activity-stream { + flex: 2 0 0; + margin-right: 20px; + max-width: calc(100% - 60px); + + .entry { + border-radius: 4px; + } + } +} + +.report-status__actions, +.account-status__actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + + .icon-button { + font-size: 24px; + width: 24px; + text-align: center; + margin-bottom: 10px; + } +} + +.simple_form.new_report_note, +.simple_form.new_account_moderation_note { + max-width: 100%; +} + +.batch-form-box { + display: flex; + flex-wrap: wrap; + margin-bottom: 5px; + + #form_status_batch_action { + margin: 0 5px 5px 0; + font-size: 14px; + } + + input.button { + margin: 0 5px 5px 0; + } + + .media-spoiler-toggle-buttons { + margin-left: auto; + + .button { + overflow: visible; + margin: 0 0 5px 5px; + float: right; + } + } +} + +.back-link { + margin-bottom: 10px; + font-size: 14px; + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.spacer { + flex: 1 1 auto; +} + +.log-entry { + margin-bottom: 20px; + line-height: 20px; + + &__header { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 10px; + background: $ui-base-color; + color: $darker-text-color; + border-radius: 4px 4px 0 0; + font-size: 14px; + position: relative; + } + + &__avatar { + margin-right: 10px; + + .avatar { + display: block; + margin: 0; + border-radius: 50%; + width: 40px; + height: 40px; + } + } + + &__content { + max-width: calc(100% - 90px); + } + + &__title { + word-wrap: break-word; + } + + &__timestamp { + color: $dark-text-color; + } + + &__extras { + background: lighten($ui-base-color, 6%); + border-radius: 0 0 4px 4px; + padding: 10px; + color: $darker-text-color; + font-family: $font-monospace, monospace; + font-size: 12px; + word-wrap: break-word; + min-height: 20px; + } + + &__icon { + font-size: 28px; + margin-right: 10px; + color: $dark-text-color; + } + + &__icon__overlay { + position: absolute; + top: 10px; + right: 10px; + width: 10px; + height: 10px; + border-radius: 50%; + + &.positive { + background: $success-green; + } + + &.negative { + background: lighten($error-red, 12%); + } + + &.neutral { + background: $ui-highlight-color; + } + } + + a, + .username, + .target { + color: $secondary-text-color; + text-decoration: none; + font-weight: 500; + } + + .diff-old { + color: lighten($error-red, 12%); + } + + .diff-neutral { + color: $secondary-text-color; + } + + .diff-new { + color: $success-green; + } +} + +a.name-tag, +.name-tag, +a.inline-name-tag, +.inline-name-tag { + text-decoration: none; + color: $secondary-text-color; + + .username { + font-weight: 500; + } + + &.suspended { + .username { + text-decoration: line-through; + color: lighten($error-red, 12%); + } + + .avatar { + filter: grayscale(100%); + opacity: 0.8; + } + } +} + +a.name-tag, +.name-tag { + display: flex; + align-items: center; + + .avatar { + display: block; + margin: 0; + margin-right: 5px; + border-radius: 50%; + } + + &.suspended { + .avatar { + filter: grayscale(100%); + opacity: 0.8; + } + } +} + +.speech-bubble { + margin-bottom: 20px; + border-left: 4px solid $ui-highlight-color; + + &.positive { + border-left-color: $success-green; + } + + &.negative { + border-left-color: lighten($error-red, 12%); + } + + &.warning { + border-left-color: $gold-star; + } + + &__bubble { + padding: 16px; + padding-left: 14px; + font-size: 15px; + line-height: 20px; + border-radius: 4px 4px 4px 0; + position: relative; + font-weight: 500; + + a { + color: $darker-text-color; + } + } + + &__owner { + padding: 8px; + padding-left: 12px; + } + + time { + color: $dark-text-color; + } +} + +.report-card { + background: $ui-base-color; + border-radius: 4px; + margin-bottom: 20px; + + &__profile { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + + .account { + padding: 0; + border: 0; + + &__avatar-wrapper { + margin-left: 0; + } + } + + &__stats { + flex: 0 0 auto; + font-weight: 500; + color: $darker-text-color; + text-transform: uppercase; + text-align: right; + + a { + color: inherit; + text-decoration: none; + + &:focus, + &:hover, + &:active { + color: lighten($darker-text-color, 8%); + } + } + + .red { + color: $error-value-color; + } + } + } + + &__summary { + &__item { + display: flex; + justify-content: flex-start; + border-top: 1px solid darken($ui-base-color, 4%); + + &:hover { + background: lighten($ui-base-color, 2%); + } + + &__reported-by, + &__assigned { + padding: 15px; + flex: 0 0 auto; + box-sizing: border-box; + width: 150px; + color: $darker-text-color; + + &, + .username { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__content { + flex: 1 1 auto; + max-width: calc(100% - 300px); + + &__icon { + color: $dark-text-color; + margin-right: 4px; + font-weight: 500; + } + } + + &__content a { + display: block; + box-sizing: border-box; + width: 100%; + padding: 15px; + text-decoration: none; + color: $darker-text-color; + } + } + } +} + +.one-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ellipsized-ip { + display: inline-block; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss new file mode 100644 index 000000000..550b7fdfc --- /dev/null +++ b/app/javascript/flavours/glitch/styles/basics.scss @@ -0,0 +1,135 @@ +@function hex-color($color) { + @if type-of($color) == 'color' { + $color: str-slice(ie-hex-str($color), 4); + } + @return '%23' + unquote($color) +} + +body { + font-family: $font-sans-serif, sans-serif; + background: darken($ui-base-color, 8%); + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + text-rendering: optimizelegibility; + font-feature-settings: "kern"; + text-size-adjust: none; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; + + &.system-font { + // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) + // -apple-system => Safari <11 specific + // BlinkMacSystemFont => Chrome <56 on macOS specific + // Segoe UI => Windows 7/8/10 + // Oxygen => KDE + // Ubuntu => Unity/Ubuntu + // Cantarell => GNOME + // Fira Sans => Firefox OS + // Droid Sans => Older Androids (<4.0) + // Helvetica Neue => Older macOS <10.11 + // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", $font-sans-serif, sans-serif; + } + + &.app-body { + position: absolute; + width: 100%; + height: 100%; + padding: 0; + background: $ui-base-color; + + &.with-modals--active { + overflow-y: hidden; + } + } + + &.lighter { + background: $ui-base-color; + } + + &.with-modals { + overflow-x: hidden; + overflow-y: scroll; + + &--active { + overflow-y: hidden; + margin-right: 13px; + } + } + + &.embed { + background: lighten($ui-base-color, 4%); + margin: 0; + padding-bottom: 0; + + .container { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + } + } + + &.admin { + background: darken($ui-base-color, 4%); + position: fixed; + width: 100%; + height: 100%; + padding: 0; + } + + &.error { + position: absolute; + text-align: center; + color: $darker-text-color; + background: $ui-base-color; + width: 100%; + height: 100%; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + + .dialog { + vertical-align: middle; + margin: 20px; + + img { + display: block; + max-width: 470px; + width: 100%; + height: auto; + margin-top: -120px; + } + + h1 { + font-size: 20px; + line-height: 28px; + font-weight: 400; + } + } + } +} + +button { + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + } +} + +.app-holder { + &, + & > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + outline: 0 !important; + } +} diff --git a/app/javascript/flavours/glitch/styles/compact_header.scss b/app/javascript/flavours/glitch/styles/compact_header.scss new file mode 100644 index 000000000..4980ab5f1 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/compact_header.scss @@ -0,0 +1,34 @@ +.compact-header { + h1 { + font-size: 24px; + line-height: 28px; + color: $darker-text-color; + font-weight: 500; + margin-bottom: 20px; + padding: 0 10px; + word-wrap: break-word; + + @media screen and (max-width: 740px) { + text-align: center; + padding: 20px 10px 0; + } + + a { + color: inherit; + text-decoration: none; + } + + small { + font-weight: 400; + color: $secondary-text-color; + } + + img { + display: inline-block; + margin-bottom: -5px; + margin-right: 15px; + width: 36px; + height: 36px; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss new file mode 100644 index 000000000..c0340e3f8 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -0,0 +1,634 @@ +.account { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + color: inherit; + text-decoration: none; + + .account__display-name { + flex: 1 1 auto; + display: block; + color: $darker-text-color; + overflow: hidden; + text-decoration: none; + font-size: 14px; + } + + &.small { + border: none; + padding: 0; + + & > .account__avatar-wrapper { margin: 0 8px 0 0 } + + & > .display-name { + height: 24px; + line-height: 24px; + } + } +} + +.account__wrapper { + display: flex; +} + +.account__avatar-wrapper { + float: left; + margin-left: 12px; + margin-right: 12px; +} + +.account__avatar { + @include avatar-radius(); + position: relative; + cursor: pointer; + + &-inline { + display: inline-block; + vertical-align: middle; + margin-right: 5px; + } +} + +.account__avatar-overlay { + position: relative; + @include avatar-size(48px); + + &-base { + @include avatar-radius(); + @include avatar-size(36px); + } + + &-overlay { + @include avatar-radius(); + @include avatar-size(24px); + + position: absolute; + bottom: 0; + right: 0; + z-index: 1; + } +} + +.account__relationship { + height: 18px; + padding: 10px; + white-space: nowrap; +} + +.account__header__wrapper { + flex: 0 0 auto; + background: lighten($ui-base-color, 4%); +} + +.account__disclaimer { + padding: 10px; + color: $dark-text-color; + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + a { + font-weight: 500; + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } +} + +.account__action-bar { + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + line-height: 36px; + overflow: hidden; + flex: 0 0 auto; + display: flex; +} + +.account__action-bar-links { + display: flex; + flex: 1 1 auto; + line-height: 18px; + text-align: center; +} + +.account__action-bar__tab { + text-decoration: none; + overflow: hidden; + flex: 0 1 100%; + border-left: 1px solid lighten($ui-base-color, 8%); + padding: 10px 0; + border-bottom: 4px solid transparent; + + &:first-child { + border-left: 0; + } + + &.active { + border-bottom: 4px solid $ui-highlight-color; + } + + & > span { + display: block; + text-transform: uppercase; + font-size: 11px; + color: $darker-text-color; + } + + strong { + display: block; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + abbr { + color: $highlight-text-color; + } +} + +.account-authorize { + padding: 14px 10px; + + .detailed-status__display-name { + display: block; + margin-bottom: 15px; + overflow: hidden; + } +} + +.account-authorize__avatar { + float: left; + margin-right: 10px; +} + +.notification__message { + margin-left: 42px; + padding: 8px 0 0 26px; + cursor: default; + color: $darker-text-color; + font-size: 15px; + position: relative; + + .fa { + color: $highlight-text-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.account--panel { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.account--panel__button, +.detailed-status__button { + flex: 1 1 auto; + text-align: center; +} + +.column-settings__outer { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-settings__section { + color: $darker-text-color; + cursor: default; + display: block; + font-weight: 500; + margin-bottom: 10px; +} + +.column-settings__hashtags { + .column-settings__row { + margin-bottom: 15px; + } + + .column-select { + &__control { + @include search-input(); + } + + &__placeholder { + color: $dark-text-color; + padding-left: 2px; + font-size: 12px; + } + + &__value-container { + padding-left: 6px; + } + + &__multi-value { + background: lighten($ui-base-color, 8%); + + &__remove { + cursor: pointer; + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 12%); + color: lighten($darker-text-color, 4%); + } + } + } + + &__multi-value__label, + &__input { + color: $darker-text-color; + } + + &__clear-indicator, + &__dropdown-indicator { + cursor: pointer; + transition: none; + color: $dark-text-color; + + &:hover, + &:active, + &:focus { + color: lighten($dark-text-color, 4%); + } + } + + &__indicator-separator { + background-color: lighten($ui-base-color, 8%); + } + + &__menu { + @include search-popout(); + padding: 0; + background: $ui-secondary-color; + } + + &__menu-list { + padding: 6px; + } + + &__option { + color: $inverted-text-color; + border-radius: 4px; + font-size: 14px; + + &--is-focused, + &--is-selected { + background: darken($ui-secondary-color, 10%); + } + } + } +} + +.column-settings__row { + .text-btn { + margin-bottom: 15px; + } +} + +.relationship-tag { + color: $primary-text-color; + margin-bottom: 4px; + display: block; + vertical-align: top; + background-color: $base-overlay-background; + text-transform: uppercase; + font-size: 11px; + font-weight: 500; + padding: 4px; + border-radius: 4px; + opacity: 0.7; + + &:hover { + opacity: 1; + } +} + +.account-gallery__container { + display: flex; + flex-wrap: wrap; + padding: 4px 2px; +} + +.account-gallery__item { + border: none; + box-sizing: border-box; + display: block; + position: relative; + border-radius: 4px; + overflow: hidden; + margin: 2px; + + &__icons { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 24px; + } +} + +.notification__filter-bar, +.account__section-headline { + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: default; + display: flex; + flex-shrink: 0; + + button { + background: darken($ui-base-color, 4%); + border: 0; + margin: 0; + } + + button, + a { + display: block; + flex: 1 1 auto; + color: $darker-text-color; + padding: 15px 0; + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + position: relative; + + &.active { + color: $secondary-text-color; + + &::before, + &::after { + display: block; + content: ""; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + transform: translateX(-50%); + border-style: solid; + border-width: 0 10px 10px; + border-color: transparent transparent lighten($ui-base-color, 8%); + } + + &::after { + bottom: -1px; + border-color: transparent transparent $ui-base-color; + } + } + } +} + +.account__moved-note { + padding: 14px 10px; + padding-bottom: 16px; + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &__message { + position: relative; + margin-left: 58px; + color: $dark-text-color; + padding: 8px 0; + padding-top: 0; + padding-bottom: 4px; + font-size: 14px; + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__icon-wrapper { + left: -26px; + position: absolute; + } + + .detailed-status__display-avatar { + position: relative; + } + + .detailed-status__display-name { + margin-bottom: 0; + } +} + +.account__header__content { + color: $darker-text-color; + font-size: 14px; + font-weight: 400; + overflow: hidden; + word-break: normal; + word-wrap: break-word; + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: inherit; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} + +.account__header { + overflow: hidden; + + &.inactive { + opacity: 0.5; + + .account__header__image, + .account__avatar { + filter: grayscale(100%); + } + } + + &__info { + position: absolute; + top: 10px; + left: 10px; + } + + &__image { + overflow: hidden; + height: 145px; + position: relative; + background: darken($ui-base-color, 4%); + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + } + } + + &__bar { + position: relative; + background: lighten($ui-base-color, 4%); + padding: 5px; + border-bottom: 1px solid lighten($ui-base-color, 12%); + + .avatar { + display: block; + flex: 0 0 auto; + width: 94px; + margin-left: -2px; + + .account__avatar { + background: darken($ui-base-color, 8%); + border: 2px solid lighten($ui-base-color, 4%); + } + } + } + + &__tabs { + display: flex; + align-items: flex-start; + padding: 7px 5px; + margin-top: -55px; + + &__buttons { + display: flex; + align-items: center; + padding-top: 55px; + overflow: hidden; + + .icon-button { + border: 1px solid lighten($ui-base-color, 12%); + border-radius: 4px; + box-sizing: content-box; + padding: 2px; + } + + .button { + margin: 0 8px; + } + } + + &__name { + padding: 5px; + + .account-role { + vertical-align: top; + } + + .emojione { + width: 22px; + height: 22px; + } + + h1 { + font-size: 16px; + line-height: 24px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + small { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .spacer { + flex: 1 1 auto; + } + } + + &__bio { + overflow: hidden; + margin: 0 -5px; + + .account__header__content { + padding: 20px 15px; + padding-bottom: 5px; + color: $primary-text-color; + } + + .account__header__fields { + margin: 0; + border-top: 1px solid lighten($ui-base-color, 12%); + + a { + color: lighten($ui-highlight-color, 8%); + } + + dl:first-child .verified { + border-radius: 0 4px 0 0; + } + + .verified a { + color: $valid-value-color; + } + } + } + + &__extra { + margin-top: 4px; + + &__links { + font-size: 14px; + color: $darker-text-color; + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + padding: 10px; + padding-top: 20px; + font-weight: 500; + + strong { + font-weight: 700; + color: $primary-text-color; + } + } + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/boost.scss b/app/javascript/flavours/glitch/styles/components/boost.scss new file mode 100644 index 000000000..f1ad041e9 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/boost.scss @@ -0,0 +1,21 @@ +button.icon-button 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($action-button-color)}' 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($highlight-text-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($action-button-color, 7%))}' 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($highlight-text-color)}' stroke-width='0'/></svg>"); + } +} + +// Disabled variant +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(darken($action-button-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(darken($action-button-color, 13%))}' stroke-width='0'/></svg>"); + } +} diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss new file mode 100644 index 000000000..7a8accc27 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -0,0 +1,529 @@ +.column__wrapper { + display: flex; + flex: 1 1 auto; + position: relative; +} + +.columns-area { + display: flex; + flex: 1 1 auto; + flex-direction: row; + justify-content: flex-start; + overflow-x: auto; + position: relative; +} + +@include limited-single-column('screen and (min-width: 360px)', $parent: null) { + .columns-area { + padding: 10px; + } + + .react-swipeable-view-container .columns-area { + height: calc(100% - 20px) !important; + } +} + +.react-swipeable-view-container { + &, + .columns-area, + .column { + height: 100%; + } +} + +.react-swipeable-view-container > * { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.column { + width: 330px; + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + + > .scrollable { + background: $ui-base-color; + } +} + +.ui { + flex: 0 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: darken($ui-base-color, 7%); +} + +.column { + overflow: hidden; +} + +@include limited-single-column('screen and (min-width: 360px)', $parent: null) { + .tabs-bar { + margin: 10px; + margin-bottom: 0; + } +} + +:root { // Overrides .wide stylings for mobile view + @include single-column('screen and (max-width: 630px)', $parent: null) { + .column { + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } + + .columns-area { + flex-direction: column; + } + + .search__input, + .autosuggest-textarea__textarea { + font-size: 16px; + } + } +} + +@include multi-columns('screen and (min-width: 631px)', $parent: null) { + .columns-area { + padding: 0; + } + + .column { + flex: 0 0 auto; + padding: 10px; + padding-left: 5px; + padding-right: 5px; + + &:first-child { + padding-left: 10px; + } + + &:last-child { + padding-right: 10px; + } + } + + .columns-area > div { + .column { + padding-left: 5px; + padding-right: 5px; + } + } +} + +.column-back-button { + background: lighten($ui-base-color, 4%); + color: $highlight-text-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + border: 0; + text-align: unset; + padding: 15px; + margin: 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } +} + +.column-header__back-button { + background: lighten($ui-base-color, 4%); + border: 0; + font-family: inherit; + color: $highlight-text-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 0 5px 0 0; + z-index: 3; + + &:hover { + text-decoration: underline; + } + + &:last-child { + padding: 0 15px 0 0; + } +} + +.column-back-button__icon { + display: inline-block; + margin-right: 5px; +} + +.column-back-button--slim { + position: relative; +} + +.column-back-button--slim-button { + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 15px; + position: absolute; + right: 0; + top: -48px; +} + +.column-link { + background: lighten($ui-base-color, 8%); + color: $primary-text-color; + display: block; + font-size: 16px; + padding: 15px; + text-decoration: none; + + &:hover { + background: lighten($ui-base-color, 11%); + } +} + +.column-link__icon { + display: inline-block; + margin-right: 5px; +} + +.column-subheading { + background: $ui-base-color; + color: $dark-text-color; + padding: 8px 20px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + cursor: default; +} + +.column-header__wrapper { + position: relative; + flex: 0 0 auto; + + &.active { + &::before { + display: block; + content: ""; + position: absolute; + top: 35px; + left: 0; + right: 0; + margin: 0 auto; + width: 60%; + pointer-events: none; + height: 28px; + z-index: 1; + background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%); + } + } +} + +.column-header { + display: flex; + font-size: 16px; + background: lighten($ui-base-color, 4%); + flex: 0 0 auto; + cursor: pointer; + position: relative; + z-index: 2; + outline: 0; + overflow: hidden; + + & > button { + margin: 0; + border: none; + padding: 15px; + color: inherit; + background: transparent; + font: inherit; + text-align: left; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; + } + + & > .column-header__back-button { + color: $highlight-text-color; + } + + &.active { + box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3); + + .column-header__icon { + color: $highlight-text-color; + text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4); + } + } + + &:focus, + &:active { + outline: 0; + } +} + +.column { + width: 330px; + position: relative; + 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; + } +} + +.column-header__buttons { + height: 48px; + display: flex; + margin-left: 0; +} + +.column-header__links .text-btn { + margin-right: 10px; +} + +.column-header__button { + background: lighten($ui-base-color, 4%); + border: 0; + color: $darker-text-color; + cursor: pointer; + font-size: 16px; + padding: 0 15px; + + &:hover { + color: lighten($darker-text-color, 7%); + } + + &.active { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + + &:hover { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + } + } + + // glitch - added focus ring for keyboard navigation + &:focus { + text-shadow: 0 0 4px darken($ui-highlight-color, 5%); + } +} + +.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 { + max-height: 70vh; + overflow: hidden; + overflow-y: auto; + color: $darker-text-color; + transition: max-height 150ms ease-in-out, opacity 300ms linear; + opacity: 1; + + &.collapsed { + max-height: 0; + opacity: 0.5; + } + + &.animating { + overflow-y: hidden; + } + + hr { + height: 0; + background: transparent; + border: 0; + border-top: 1px solid lighten($ui-base-color, 12%); + margin: 10px 0; + } + + // notif cleaning drawer + &.ncd { + transition: none; + &.collapsed { + max-height: 0; + opacity: 0.7; + } + } +} + +.column-header__collapsible-inner { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-header__setting-btn { + &:hover { + color: $darker-text-color; + text-decoration: underline; + } +} + +.column-header__setting-arrows { + float: right; + + .column-header__setting-btn { + padding: 0 10px; + + &:last-child { + padding-right: 0; + } + } +} + +.column-header__title { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + +.column-header__icon { + display: inline-block; + margin-right: 5px; +} + +.empty-column-indicator, +.error-column { + color: $dark-text-color; + background: $ui-base-color; + text-align: center; + padding: 20px; + font-size: 15px; + font-weight: 400; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + @supports(display: grid) { // hack to fix Chrome <57 + contain: strict; + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.error-column { + flex-direction: column; +} + +// 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; + } +} + +.column-inline-form { + padding: 7px 15px; + padding-right: 5px; + display: flex; + justify-content: flex-start; + align-items: center; + background: lighten($ui-base-color, 4%); + + label { + flex: 1 1 auto; + + input { + width: 100%; + margin-bottom: 6px; + + &:focus { + outline: 0; + } + } + } + + .icon-button { + flex: 0 0 auto; + margin: 0 5px; + } +} + +.floating-action-button { + position: fixed; + display: flex; + justify-content: center; + align-items: center; + width: 3.9375rem; + height: 3.9375rem; + bottom: 1.3125rem; + right: 1.3125rem; + background: darken($ui-highlight-color, 3%); + color: $white; + border-radius: 50%; + font-size: 21px; + line-height: 21px; + text-decoration: none; + box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4); + + &:hover, + &:focus, + &:active { + background: lighten($ui-highlight-color, 7%); + } +} diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss new file mode 100644 index 000000000..86041da20 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -0,0 +1,517 @@ +.composer { + padding: 10px; +} + +.no-reduce-motion .composer--spoiler { + transition: height 0.4s ease, opacity 0.4s ease; +} + +.composer--spoiler { + height: 0; + transform-origin: bottom; + opacity: 0.0; + + &.composer--spoiler--visible { + height: 47px; + opacity: 1.0; + } + + input { + display: block; + box-sizing: border-box; + margin: 0; + border: none; + border-radius: 4px; + padding: 10px; + width: 100%; + outline: 0; + color: $inverted-text-color; + background: $simple-background-color; + font-size: 14px; + font-family: inherit; + resize: vertical; + + &:focus { outline: 0 } + @include single-column('screen and (max-width: 630px)') { font-size: 16px } + } +} + +.composer--warning { + color: $inverted-text-color; + margin-bottom: 15px; + background: $ui-primary-color; + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3); + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + font-weight: 400; + + a { + color: $lighter-text-color; + font-weight: 500; + text-decoration: underline; + + &:active, + &:focus, + &:hover { text-decoration: none } + } +} + +.compose-form__sensitive-button { + padding: 10px; + padding-top: 0; + + .icon-button { + font-size: 14px; + font-weight: 500; + } +} + +.composer--reply { + margin: 0 0 10px; + border-radius: 4px; + padding: 10px; + background: $ui-primary-color; + + & > header { + margin-bottom: 5px; + overflow: hidden; + + & > .account.small { color: $inverted-text-color; } + + & > .cancel { + float: right; + line-height: 24px; + } + } + + & > .content { + position: relative; + margin: 10px 0; + padding: 0 12px; + font-size: 14px; + line-height: 20px; + color: $inverted-text-color; + word-wrap: break-word; + font-weight: 400; + overflow: visible; + white-space: pre-wrap; + padding-top: 5px; + + p { + margin-bottom: 20px; + + &:last-child { margin-bottom: 0 } + } + + a { + color: $lighter-text-color; + text-decoration: none; + + &:hover { text-decoration: underline } + + &.mention { + &:hover { + text-decoration: none; + + span { text-decoration: underline } + } + } + } + } + + .emojione { + width: 20px; + height: 20px; + margin: -5px 0 0; + } +} + +.emoji-picker-dropdown { + position: absolute; + right: 5px; + top: 5px; + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } +} + +.composer--textarea, +.autosuggest-input { + position: relative; + + label { + .autosuggest-textarea__textarea { + display: block; + box-sizing: border-box; + margin: 0; + border: none; + border-radius: 4px 4px 0 0; + padding: 10px 32px 0 10px; + width: 100%; + min-height: 100px; + outline: 0; + color: $inverted-text-color; + background: $simple-background-color; + font-size: 14px; + font-family: inherit; + resize: none; + scrollbar-color: initial; + + &::-webkit-scrollbar { + all: unset; + } + + &:disabled { background: $ui-secondary-color } + &:focus { outline: 0 } + @include single-column('screen and (max-width: 630px)') { font-size: 16px } + + @include limited-single-column('screen and (max-width: 600px)') { + height: 100px !important; // prevent auto-resize textarea + resize: vertical; + } + } + } +} + +.composer--textarea--icons { + display: block; + position: absolute; + top: 29px; + right: 5px; + bottom: 5px; + overflow: hidden; + + & > .textarea_icon { + display: block; + margin: 2px 0 0 2px; + width: 24px; + height: 24px; + color: $lighter-text-color; + font-size: 18px; + line-height: 24px; + text-align: center; + opacity: .8; + } +} + +.autosuggest-textarea__suggestions { + display: block; + position: absolute; + box-sizing: border-box; + top: 100%; + border-radius: 0 0 4px 4px; + padding: 6px; + width: 100%; + color: $inverted-text-color; + background: $ui-secondary-color; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + font-size: 14px; + z-index: 99; + display: none; +} + +.autosuggest-textarea__suggestions--visible { + display: block; +} + +.autosuggest-textarea__suggestions__item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + border-radius: 4px; + padding: 10px; + font-size: 14px; + line-height: 18px; + overflow: hidden; + cursor: pointer; + + &:hover, + &:focus, + &:active, + &.selected { background: darken($ui-secondary-color, 10%) } + + & > .emoji { + img { + display: block; + float: left; + margin-right: 8px; + width: 18px; + height: 18px; + } + } + + & > .account.small { + .display-name { + & > span { color: $lighter-text-color } + } + } +} + +.composer--upload_form { + overflow: hidden; + + & > .content { + display: flex; + flex-direction: row; + flex-wrap: wrap; + font-family: inherit; + padding: 5px; + overflow: hidden; + } +} + +.composer--upload_form--item { + flex: 1 1 0; + margin: 5px; + min-width: 40%; + + & > div { + position: relative; + border-radius: 4px; + height: 140px; + width: 100%; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + overflow: hidden; + + textarea { + display: block; + position: absolute; + box-sizing: border-box; + bottom: 0; + left: 0; + margin: 0; + border: 0; + padding: 10px; + width: 100%; + color: $secondary-text-color; + background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); + font-size: 14px; + font-family: inherit; + font-weight: 500; + opacity: 0; + z-index: 2; + transition: opacity .1s ease; + + &:focus { color: $white } + + &::placeholder { + opacity: 0.54; + color: $secondary-text-color; + } + } + + & > .close { mix-blend-mode: difference } + } + + &.active { + & > div { + textarea { opacity: 1 } + } + } +} + +.composer--upload_form--actions { + background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); + display: flex; + align-items: flex-start; + justify-content: space-between; + opacity: 0; + transition: opacity .1s ease; + + .icon-button { + flex: 0 1 auto; + color: $ui-secondary-color; + font-size: 14px; + font-weight: 500; + padding: 10px; + font-family: inherit; + + &:hover, + &:focus, + &:active { + color: lighten($ui-secondary-color, 4%); + } + } + + &.active { + opacity: 1; + } +} + +.composer--upload_form--progress { + display: flex; + padding: 10px; + color: $darker-text-color; + overflow: hidden; + + & > .fa { + font-size: 34px; + margin-right: 10px; + } + + & > .message { + flex: 1 1 auto; + + & > span { + display: block; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + } + + & > .backdrop { + position: relative; + margin-top: 5px; + border-radius: 6px; + width: 100%; + height: 6px; + background: $ui-base-lighter-color; + + & > .tracker { + position: absolute; + top: 0; + left: 0; + height: 6px; + border-radius: 6px; + background: $ui-highlight-color; + } + } + } +} + +.compose-form__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $simple-background-color; +} + +.composer--options { + padding: 10px; + background: darken($simple-background-color, 8%); + box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05); + border-radius: 0 0 4px 4px; + height: 27px; + + & > * { + display: inline-block; + box-sizing: content-box; + padding: 0 3px; + height: 27px; + line-height: 27px; + vertical-align: bottom; + } + + & > hr { + display: inline-block; + margin: 0 3px; + border-width: 0 0 0 1px; + border-style: none none none solid; + border-color: transparent transparent transparent darken($simple-background-color, 24%); + padding: 0; + width: 0; + height: 27px; + background: transparent; + } +} + +.composer--options--dropdown { + &.open { + & > .value { + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + color: $primary-text-color; + background: $ui-highlight-color; + transition: none; + } + &.top { + & > .value { + border-radius: 0 0 4px 4px; + box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1); + } + } + } +} + +.composer--options--dropdown--content { + position: absolute; + border-radius: 4px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + background: $simple-background-color; + overflow: hidden; + transform-origin: 50% 0; +} + +.composer--options--dropdown--content--item { + display: flex; + align-items: center; + padding: 10px; + color: $inverted-text-color; + cursor: pointer; + + & > .content { + flex: 1 1 auto; + color: $lighter-text-color; + + &:not(:first-child) { margin-left: 10px } + + strong { + display: block; + color: $inverted-text-color; + font-weight: 500; + } + } + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + & > .content { + color: $primary-text-color; + + strong { color: $primary-text-color } + } + } + + &.active:hover { background: lighten($ui-highlight-color, 4%) } +} + +.composer--publisher { + padding-top: 10px; + text-align: right; + white-space: nowrap; + overflow: hidden; + + & > .count { + display: inline-block; + margin: 0 16px 0 8px; + font-size: 16px; + line-height: 36px; + } + + & > .primary { + display: inline-block; + margin: 0; + padding: 0 10px; + text-align: center; + } + + & > .side_arm { + display: inline-block; + margin: 0 2px 0 0; + padding: 0; + width: 36px; + text-align: center; + } + + &.over { + & > .count { color: $warning-red } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/domains.scss b/app/javascript/flavours/glitch/styles/components/domains.scss new file mode 100644 index 000000000..a99ccd02b --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/domains.scss @@ -0,0 +1,23 @@ +.domain { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .domain__domain-name { + flex: 1 1 auto; + display: block; + color: $primary-text-color; + text-decoration: none; + font-size: 14px; + font-weight: 500; + } +} + +.domain__wrapper { + display: flex; +} + +.domain_buttons { + height: 18px; + padding: 10px; + white-space: nowrap; +} diff --git a/app/javascript/flavours/glitch/styles/components/doodle.scss b/app/javascript/flavours/glitch/styles/components/doodle.scss new file mode 100644 index 000000000..a4a1cfc84 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/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/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss new file mode 100644 index 000000000..9f426448f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -0,0 +1,332 @@ +.drawer { + width: 300px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow-y: hidden; + padding: 10px 5px; + flex: none; + + &:first-child { + padding-left: 10px; + } + + &:last-child { + padding-right: 10px; + } + + @include single-column('screen and (max-width: 630px)') { flex: auto } + + @include limited-single-column('screen and (max-width: 630px)') { + &, &:first-child, &:last-child { padding: 0 } + } + + .wide & { + min-width: 300px; + max-width: 400px; + flex: 1 1 200px; + } + + @include single-column('screen and (max-width: 630px)') { + :root & { // Overrides `.wide` for single-column view + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } + } + + .react-swipeable-view-container & { height: 100% } +} + +.drawer--header { + display: flex; + flex-direction: row; + margin-bottom: 10px; + flex: none; + background: lighten($ui-base-color, 8%); + font-size: 16px; + + & > * { + display: block; + box-sizing: border-box; + border-bottom: 2px solid transparent; + padding: 15px 5px 13px; + height: 48px; + flex: 1 1 auto; + color: $darker-text-color; + text-align: center; + text-decoration: none; + cursor: pointer; + } + + a { + transition: background 100ms ease-in; + + &:focus, + &:hover { + outline: none; + background: lighten($ui-base-color, 3%); + transition: background 200ms ease-out; + } + } +} + +.drawer--search { + position: relative; + margin-bottom: 10px; + flex: none; + + @include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 } + @include single-column('screen and (max-width: 630px)') { font-size: 16px } + + input { + display: block; + box-sizing: border-box; + margin: 0; + border: none; + padding: 10px 30px 10px 10px; + width: 100%; + height: 36px; + outline: 0; + color: $darker-text-color; + background: $ui-base-color; + font-size: 14px; + font-family: inherit; + line-height: 16px; + + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + } + } + + & > .icon { + display: block; + position: absolute; + top: 10px; + right: 10px; + width: 18px; + height: 18px; + color: $secondary-text-color; + font-size: 18px; + line-height: 18px; + z-index: 2; + + .fa { + display: inline-block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + opacity: 0; + cursor: default; + pointer-events: none; + transition: all 100ms linear; + transition-property: color, transform, opacity; + } + + .fa-search { + opacity: 0.3; + transform: rotate(0deg); + } + + .fa-times-circle { + transform: rotate(-90deg); + cursor: pointer; + + &:hover { color: $primary-text-color } + } + } + + &.active { + & > .icon { + .fa-search { + opacity: 0; + transform: rotate(90deg); + } + + .fa-times-circle { + opacity: 0.3; + pointer-events: auto; + transform: rotate(0deg); + } + } + } +} + +.drawer--search--popout { + @include search-popout(); +} + +.drawer--account { + padding: 10px; + color: $darker-text-color; + + & > a { + color: inherit; + text-decoration: none; + } + + & > .avatar { + float: left; + margin-right: 10px; + } + + & > .acct { + display: block; + color: $secondary-text-color; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.drawer--results { + background: $ui-base-color; + overflow-x: hidden; + overflow-y: auto; + + & > header { + color: $dark-text-color; + background: lighten($ui-base-color, 2%); + padding: 15px; + font-weight: 500; + font-size: 16px; + cursor: default; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + & > section { + margin-bottom: 5px; + + h5 { + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: default; + display: flex; + padding: 15px; + font-weight: 500; + font-size: 16px; + color: $dark-text-color; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + .account:last-child, + & > div:last-child .status { + border-bottom: 0; + } + + & > .hashtag { + display: block; + padding: 10px; + color: $secondary-text-color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + color: lighten($secondary-text-color, 4%); + text-decoration: underline; + } + } + } +} + +.drawer__pager { + box-sizing: border-box; + padding: 0; + flex-grow: 1; + 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%; + height: 100%; + + &.darker { + background: $ui-base-color; + } +} + +.drawer__inner__mastodon { + background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') no-repeat bottom / 100% auto; + flex: 1; + min-height: 47px; + + > img { + display: block; + object-fit: contain; + object-position: bottom left; + width: 100%; + height: 100%; + pointer-events: none; + user-drag: none; + user-select: none; + } + + > .mastodon { + display: block; + width: 100%; + height: 100%; + border: none; + cursor: inherit; + } +} + +.pseudo-drawer { + background: lighten($ui-base-color, 13%); + font-size: 13px; + text-align: left; +} + +.drawer__backdrop { + cursor: pointer; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba($base-overlay-background, 0.5); +} + +@for $i from 0 through 3 { + .mbstobon-#{$i} .drawer__inner__mastodon { + @if $i == 3 { + background: url('~flavours/glitch/images/wave-drawer.png') no-repeat bottom / 100% auto, lighten($ui-base-color, 13%); + } @else { + background: url('~flavours/glitch/images/wave-drawer-glitched.png') no-repeat bottom / 100% auto, lighten($ui-base-color, 13%); + } + + & > .mastodon { + background: url("~flavours/glitch/images/mbstobon-ui-#{$i}.png") no-repeat left bottom / contain; + + @if $i != 3 { + filter: contrast(50%) brightness(50%); + } + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/emoji.scss b/app/javascript/flavours/glitch/styles/components/emoji.scss new file mode 100644 index 000000000..dd386d698 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/emoji.scss @@ -0,0 +1,104 @@ +.emojione { + font-size: inherit; + vertical-align: middle; + object-fit: contain; + margin: -.2ex .15em .2ex; + width: 16px; + height: 16px; + + img { + width: auto; + } +} + +.emoji-picker-dropdown__menu { + background: $simple-background-color; + position: absolute; + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + margin-top: 5px; + + .emoji-mart-scroll { + transition: opacity 200ms ease; + } + + &.selecting .emoji-mart-scroll { + opacity: 0.5; + } +} + +.emoji-picker-dropdown__modifiers { + position: absolute; + top: 60px; + right: 11px; + cursor: pointer; +} + +.emoji-picker-dropdown__modifiers__menu { + position: absolute; + z-index: 4; + top: -4px; + left: -8px; + background: $simple-background-color; + border-radius: 4px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + overflow: hidden; + + button { + display: block; + cursor: pointer; + border: 0; + padding: 4px 8px; + background: transparent; + + &:hover, + &:focus, + &:active { + background: rgba($ui-secondary-color, 0.4); + } + } + + .emoji-mart-emoji { + height: 22px; + } +} + +.emoji-mart-emoji { + span { + background-repeat: no-repeat; + } +} + +.emoji-button { + display: block; + font-size: 24px; + line-height: 24px; + margin-left: 2px; + width: 24px; + outline: 0; + cursor: pointer; + + &:active, + &:focus { + outline: 0 !important; + } + + img { + filter: grayscale(100%); + opacity: 0.8; + display: block; + margin: 0; + width: 22px; + height: 22px; + margin-top: 2px; + } + + &:hover, + &:active, + &:focus { + img { + opacity: 1; + filter: none; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/emoji_picker.scss b/app/javascript/flavours/glitch/styles/components/emoji_picker.scss new file mode 100644 index 000000000..dcc551c5b --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/emoji_picker.scss @@ -0,0 +1,204 @@ +.emoji-mart { + &, + * { + box-sizing: border-box; + line-height: 1.15; + } + + font-size: 13px; + display: inline-block; + color: $inverted-text-color; + + .emoji-mart-emoji { + padding: 6px; + } +} + +.emoji-mart-bar { + border: 0 solid darken($ui-secondary-color, 8%); + + &:first-child { + border-bottom-width: 1px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background: $ui-secondary-color; + } + + &:last-child { + border-top-width: 1px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + display: none; + } +} + +.emoji-mart-anchors { + display: flex; + justify-content: space-between; + padding: 0 6px; + color: $lighter-text-color; + line-height: 0; +} + +.emoji-mart-anchor { + position: relative; + flex: 1; + text-align: center; + padding: 12px 4px; + overflow: hidden; + transition: color .1s ease-out; + cursor: pointer; + + &:hover { + color: darken($lighter-text-color, 4%); + } +} + +.emoji-mart-anchor-selected { + color: $highlight-text-color; + + &:hover { + color: darken($highlight-text-color, 4%); + } + + .emoji-mart-anchor-bar { + bottom: 0; + } +} + +.emoji-mart-anchor-bar { + position: absolute; + bottom: -3px; + left: 0; + width: 100%; + height: 3px; + background-color: darken($ui-highlight-color, 3%); +} + +.emoji-mart-anchors { + i { + display: inline-block; + width: 100%; + max-width: 22px; + } + + svg { + fill: currentColor; + max-height: 18px; + } +} + +.emoji-mart-scroll { + overflow-y: scroll; + height: 270px; + max-height: 35vh; + padding: 0 6px 6px; + background: $simple-background-color; + will-change: transform; + + &::-webkit-scrollbar-track:hover, + &::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } +} + +.emoji-mart-search { + padding: 10px; + padding-right: 45px; + background: $simple-background-color; + + input { + font-size: 14px; + font-weight: 400; + padding: 7px 9px; + font-family: inherit; + display: block; + width: 100%; + background: rgba($ui-secondary-color, 0.3); + color: $inverted-text-color; + border: 1px solid $ui-secondary-color; + border-radius: 4px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + } +} + +.emoji-mart-category .emoji-mart-emoji { + cursor: pointer; + + span { + z-index: 1; + position: relative; + text-align: center; + } + + &:hover::before { + z-index: 0; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba($ui-secondary-color, 0.7); + border-radius: 100%; + } +} + +.emoji-mart-category-label { + z-index: 2; + position: relative; + position: -webkit-sticky; + position: sticky; + top: 0; + + span { + display: block; + width: 100%; + font-weight: 500; + padding: 5px 6px; + background: $simple-background-color; + } +} + +.emoji-mart-emoji { + position: relative; + display: inline-block; + font-size: 0; + + span { + width: 22px; + height: 22px; + } +} + +.emoji-mart-no-results { + font-size: 14px; + text-align: center; + padding-top: 70px; + color: $light-text-color; + + .emoji-mart-category-label { + display: none; + } + + .emoji-mart-no-results-label { + margin-top: .2em; + } + + .emoji-mart-emoji:hover::before { + content: none; + } +} + +.emoji-mart-preview { + display: none; +} diff --git a/app/javascript/flavours/glitch/styles/components/error_boundary.scss b/app/javascript/flavours/glitch/styles/components/error_boundary.scss new file mode 100644 index 000000000..f9bf425f8 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/error_boundary.scss @@ -0,0 +1,32 @@ +.error-boundary { + h1 { + font-size: 26px; + line-height: 36px; + font-weight: 400; + margin-bottom: 8px; + } + + p { + color: $primary-text-color; + font-size: 15px; + line-height: 20px; + + a { + color: $primary-text-color; + text-decoration: underline; + } + + ul { + list-style: disc; + margin-left: 0; + padding-left: 1em; + } + + textarea.web_app_crash-stacktrace { + width: 100%; + resize: none; + white-space: pre; + font-family: $font-monospace, monospace; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss new file mode 100644 index 000000000..63211392e --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -0,0 +1,1324 @@ +.app-body { + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.button { + background-color: darken($ui-highlight-color, 3%); + border: 10px none; + border-radius: 4px; + box-sizing: border-box; + color: $primary-text-color; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: 14px; + font-weight: 500; + height: 36px; + letter-spacing: 0; + line-height: 36px; + overflow: hidden; + padding: 0 16px; + position: relative; + text-align: center; + text-transform: uppercase; + text-decoration: none; + text-overflow: ellipsis; + transition: all 100ms ease-in; + transition-property: background-color; + white-space: nowrap; + width: auto; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-highlight-color, 7%); + transition: all 200ms ease-out; + transition-property: background-color; + } + + &--destructive { + transition: none; + + &:active, + &:focus, + &:hover { + background-color: $error-red; + transition: none; + } + } + + &:disabled { + background-color: $ui-primary-color; + cursor: default; + } + + &.button-primary, + &.button-alternative, + &.button-secondary, + &.button-alternative-2 { + font-size: 16px; + line-height: 36px; + height: auto; + text-transform: none; + padding: 4px 16px; + } + + &.button-alternative { + color: $inverted-text-color; + background: $ui-primary-color; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-primary-color, 4%); + } + } + + &.button-alternative-2 { + background: $ui-base-lighter-color; + + &:active, + &:focus, + &:hover { + background-color: lighten($ui-base-lighter-color, 4%); + } + } + + &.button-secondary { + font-size: 16px; + line-height: 36px; + height: auto; + color: $darker-text-color; + text-transform: none; + background: transparent; + padding: 3px 15px; + border-radius: 4px; + border: 1px solid $ui-primary-color; + + &:active, + &:focus, + &:hover { + border-color: lighten($ui-primary-color, 4%); + color: lighten($darker-text-color, 4%); + } + + &:disabled { + opacity: 0.5; + } + } + + &.button--block { + display: block; + width: 100%; + } +} + +.icon-button { + display: inline-block; + padding: 0; + color: $action-button-color; + border: none; + background: transparent; + cursor: pointer; + transition: color 100ms ease-in; + + &:hover, + &:active, + &:focus { + color: lighten($action-button-color, 7%); + transition: color 200ms ease-out; + } + + &.disabled { + color: darken($action-button-color, 13%); + cursor: default; + } + + &.active { + color: $highlight-text-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &.inverted { + color: $lighter-text-color; + + &:hover, + &:active, + &:focus { + color: darken($lighter-text-color, 7%); + } + + &.disabled { + color: lighten($lighter-text-color, 7%); + } + + &.active { + color: $highlight-text-color; + + &.disabled { + color: lighten($highlight-text-color, 13%); + } + } + } + + &.overlayed { + box-sizing: content-box; + background: rgba($base-overlay-background, 0.6); + color: rgba($primary-text-color, 0.7); + border-radius: 4px; + padding: 2px; + + &:hover { + background: rgba($base-overlay-background, 0.9); + } + } +} + +.text-icon-button { + color: $lighter-text-color; + border: none; + background: transparent; + cursor: pointer; + font-weight: 600; + font-size: 11px; + padding: 0 3px; + line-height: 27px; + outline: 0; + transition: color 100ms ease-in; + + &:hover, + &:active, + &:focus { + color: darken($lighter-text-color, 7%); + transition: color 200ms ease-out; + } + + &.disabled { + color: lighten($lighter-text-color, 20%); + cursor: default; + } + + &.active { + color: $highlight-text-color; + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } +} + +.dropdown-menu { + position: absolute; + transform-origin: 50% 0; +} + +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; + height: 0; + position: absolute; + + img, + svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; + } +} + +.ellipsis { + &::after { + content: "…"; + } +} + +.notification__favourite-icon-wrapper { + left: 0; + position: absolute; + + .fa.star-icon { + color: $gold-star; + } +} + +.star-icon.active { + color: $gold-star; +} + +.bookmark-icon.active { + color: $red-bookmark; +} + +.notification__display-name { + color: inherit; + font-weight: 500; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } +} + +.display-name { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + strong { + display: block; + height: 18px; + font-size: 16px; + font-weight: 500; + line-height: 18px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + span { + display: block; + height: 18px; + font-size: 15px; + line-height: 18px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &:hover { + strong { + text-decoration: underline; + } + } + + &.inline { + padding: 0; + height: 18px; + font-size: 15px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + strong { + display: inline; + height: auto; + font-size: inherit; + line-height: inherit; + } + + span { + display: inline; + height: auto; + font-size: inherit; + line-height: inherit; + } + } +} + +.display-name__html { + font-weight: 500; +} + +.display-name__account { + font-size: 14px; +} + +.image-loader { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + .image-loader__preview-canvas { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + background: url('~images/void.png') repeat; + object-fit: contain; + } + + .loading-bar { + position: relative; + } + + &.image-loader--amorphous .image-loader__preview-canvas { + display: none; + } +} + +.zoomable-image { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + width: auto; + height: auto; + object-fit: contain; + } +} + +.dropdown { + display: inline-block; +} + +.dropdown__content { + display: none; + position: absolute; +} + +.dropdown-menu__separator { + border-bottom: 1px solid darken($ui-secondary-color, 8%); + margin: 5px 7px 6px; + height: 0; +} + +.dropdown-menu { + background: $ui-secondary-color; + padding: 4px 0; + border-radius: 4px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + ul { + list-style: none; + } +} + +.dropdown-menu__arrow { + position: absolute; + width: 0; + height: 0; + border: 0 solid transparent; + + &.left { + right: -5px; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: $ui-secondary-color; + } + + &.top { + bottom: -5px; + margin-left: -7px; + border-width: 5px 7px 0; + border-top-color: $ui-secondary-color; + } + + &.bottom { + top: -5px; + margin-left: -7px; + border-width: 0 7px 5px; + border-bottom-color: $ui-secondary-color; + } + + &.right { + left: -5px; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: $ui-secondary-color; + } +} + +.dropdown-menu__item { + a { + font-size: 13px; + line-height: 18px; + display: block; + padding: 4px 14px; + box-sizing: border-box; + text-decoration: none; + background: $ui-secondary-color; + color: $inverted-text-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus, + &:hover, + &:active { + background: $ui-highlight-color; + color: $secondary-text-color; + outline: 0; + } + } +} + +.dropdown--active .dropdown__content { + display: block; + line-height: 18px; + max-width: 311px; + right: 0; + text-align: left; + z-index: 9999; + + & > ul { + list-style: none; + background: $ui-secondary-color; + padding: 4px 0; + border-radius: 4px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); + min-width: 140px; + position: relative; + } + + &.dropdown__right { + right: 0; + } + + &.dropdown__left { + & > ul { + left: -98px; + } + } + + & > ul > li > a { + font-size: 13px; + line-height: 18px; + display: block; + padding: 4px 14px; + box-sizing: border-box; + text-decoration: none; + background: $ui-secondary-color; + color: $inverted-text-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + outline: 0; + } + + &:hover { + background: $ui-highlight-color; + color: $secondary-text-color; + } + } +} + +.dropdown__icon { + vertical-align: middle; +} + +.static-content { + padding: 10px; + padding-top: 20px; + color: $dark-text-color; + + h1 { + font-size: 16px; + font-weight: 500; + margin-bottom: 40px; + text-align: center; + } + + p { + font-size: 13px; + margin-bottom: 20px; + } +} + +.tabs-bar { + display: flex; + background: lighten($ui-base-color, 8%); + flex: 0 0 auto; + overflow-y: auto; +} + +.tabs-bar__link { + display: block; + flex: 1 1 auto; + padding: 15px 10px; + color: $primary-text-color; + text-decoration: none; + text-align: center; + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid lighten($ui-base-color, 8%); + transition: all 50ms linear; + transition-property: border-bottom, background, color; + + .fa { + font-weight: 400; + font-size: 16px; + } + + &.active { + border-bottom: 2px solid $ui-highlight-color; + color: $highlight-text-color; + } + + &:hover, + &:focus, + &:active { + @include multi-columns('screen and (min-width: 631px)') { + background: lighten($ui-base-color, 14%); + } + } + + span:last-child { + margin-left: 5px; + display: none; + } +} + +@include multi-columns('screen and (min-width: 631px)', $parent: null) { + .tabs-bar { + display: none; + } +} + +.scrollable { + overflow-y: scroll; + overflow-x: hidden; + flex: 1 1 auto; + -webkit-overflow-scrolling: touch; + will-change: transform; // improves perf in mobile Chrome + + &.optionally-scrollable { + overflow-y: auto; + } + + @supports(display: grid) { // hack to fix Chrome <57 + contain: strict; + } + + &--flex { + display: flex; + flex-direction: column; + } + + &__append { + flex: 1 1 auto; + position: relative; + min-height: 120px; + } +} + +.scrollable.fullscreen { + @supports(display: grid) { // hack to fix Chrome <57 + contain: none; + } +} + +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + user-select: none; + -webkit-tap-highlight-color: rgba($base-overlay-background, 0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + cursor: not-allowed; + opacity: 0.5; + transition: opacity 0.25s; +} + +.react-toggle-track { + width: 50px; + height: 24px; + padding: 0; + border-radius: 30px; + background-color: $ui-base-color; + transition: background-color 0.2s ease; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: darken($ui-base-color, 10%); +} + +.react-toggle--checked .react-toggle-track { + background-color: $ui-highlight-color; +} + +.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: lighten($ui-highlight-color, 10%); +} + +.react-toggle-track-check { + position: absolute; + width: 14px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + left: 8px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle-track-x { + position: absolute; + width: 10px; + height: 10px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + right: 10px; + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + position: absolute; + top: 1px; + left: 1px; + width: 22px; + height: 22px; + border: 1px solid $ui-base-color; + border-radius: 50%; + background-color: darken($simple-background-color, 2%); + box-sizing: border-box; + transition: all 0.25s ease; + transition-property: border-color, left; +} + +.react-toggle--checked .react-toggle-thumb { + left: 27px; + border-color: $ui-highlight-color; +} + +.getting-started__wrapper, +.getting_started { + background: $ui-base-color; +} + +.getting-started__wrapper { + position: relative; + overflow-y: auto; +} + +.getting-started { + background: $ui-base-color; + flex: 1 0 auto; + + p { + color: $secondary-text-color; + } + + a { + color: $dark-text-color; + } + + &__footer { + flex: 0 0 auto; + padding: 10px; + padding-top: 20px; + + ul { + margin-bottom: 10px; + } + + ul li { + display: inline; + } + + p { + color: $dark-text-color; + font-size: 13px; + margin-bottom: 20px; + + a { + color: $dark-text-color; + text-decoration: underline; + } + } + + a { + text-decoration: none; + color: $darker-text-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + } +} + +.column-link__badge { + display: inline-block; + border-radius: 4px; + font-size: 12px; + line-height: 19px; + font-weight: 500; + background: $ui-base-color; + padding: 4px 8px; + margin: -6px 10px; +} + +.keyboard-shortcuts { + padding: 8px 0 0; + overflow: hidden; + + thead { + position: absolute; + left: -9999px; + } + + td { + padding: 0 10px 8px; + } + + kbd { + display: inline-block; + padding: 3px 5px; + background-color: lighten($ui-base-color, 8%); + border: 1px solid darken($ui-base-color, 4%); + } +} + +.setting-text { + color: $darker-text-color; + background: transparent; + border: none; + border-bottom: 2px solid $ui-primary-color; + box-sizing: border-box; + display: block; + font-family: inherit; + margin-bottom: 10px; + padding: 7px 0; + width: 100%; + + &:focus, + &:active { + color: $primary-text-color; + border-bottom-color: $ui-highlight-color; + } + + @include limited-single-column('screen and (max-width: 600px)') { + font-size: 16px; + } + + &.light { + color: $inverted-text-color; + border-bottom: 2px solid lighten($ui-base-color, 27%); + + &:focus, + &:active { + color: $inverted-text-color; + border-bottom-color: $ui-highlight-color; + } + } +} + +.no-reduce-motion button.icon-button i.fa-retweet { + background-position: 0 0; + height: 19px; + transition: background-position 0.9s steps(10); + transition-duration: 0s; + vertical-align: middle; + width: 22px; + + &::before { + display: none !important; + } +} + +.no-reduce-motion button.icon-button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.reduce-motion button.icon-button i.fa-retweet { + color: $action-button-color; + transition: color 100ms ease-in; +} + +.reduce-motion button.icon-button.active i.fa-retweet { + color: $highlight-text-color; +} + +.reduce-motion button.icon-button.disabled i.fa-retweet { + color: darken($action-button-color, 13%); +} + +.load-more { + display: block; + color: $dark-text-color; + background-color: transparent; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + box-sizing: border-box; + width: 100%; + clear: both; + text-decoration: none; + + &:hover { + background: lighten($ui-base-color, 2%); + } +} + +.load-gap { + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.missing-indicator { + padding-top: 20px + 48px; + + .regeneration-indicator__figure { + background-image: url('~flavours/glitch/images/elephant_ui_disappointed.svg'); + } +} + +.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: $darker-text-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; + } + } +} + +.text-btn { + display: inline-block; + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + border: 0; + background: transparent; + cursor: pointer; +} + +.loading-indicator { + color: $dark-text-color; + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + overflow: visible; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + span { + display: block; + float: left; + margin-left: 50%; + transform: translateX(-50%); + margin: 82px 0 0 50%; + white-space: nowrap; + } +} + +.loading-indicator__figure { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 42px; + height: 42px; + box-sizing: border-box; + background-color: transparent; + border: 0 solid lighten($ui-base-color, 26%); + border-width: 6px; + border-radius: 50%; +} + +.no-reduce-motion .loading-indicator span { + animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); +} + +.no-reduce-motion .loading-indicator__figure { + animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); +} + +@keyframes loader-figure { + 0% { + width: 0; + height: 0; + background-color: lighten($ui-base-color, 26%); + } + + 29% { + background-color: lighten($ui-base-color, 26%); + } + + 30% { + width: 42px; + height: 42px; + background-color: transparent; + border-width: 21px; + opacity: 1; + } + + 100% { + width: 42px; + height: 42px; + border-width: 0; + opacity: 0; + background-color: transparent; + } +} + +@keyframes loader-label { + 0% { opacity: 0.25; } + 30% { opacity: 1; } + 100% { opacity: 0.25; } +} + +.spoiler-button { + top: 0; + left: 0; + width: 100%; + height: 100%; + position: absolute; + z-index: 100; + + &--minified { + display: flex; + left: 4px; + top: 4px; + width: auto; + height: auto; + align-items: center; + } + + &--hidden { + display: none; + } + + &__overlay { + display: block; + background: transparent; + width: 100%; + height: 100%; + border: 0; + + &__label { + display: inline-block; + background: rgba($base-overlay-background, 0.5); + border-radius: 8px; + padding: 8px 12px; + color: $primary-text-color; + font-weight: 500; + font-size: 14px; + } + + &:hover, + &:focus, + &:active { + .spoiler-button__overlay__label { + background: rgba($base-overlay-background, 0.8); + } + } + } +} + +.setting-toggle { + display: block; + line-height: 24px; +} + +.setting-toggle__label, +.setting-radio__label, +.setting-meta__label { + color: $darker-text-color; + display: inline-block; + margin-bottom: 14px; + margin-left: 8px; + vertical-align: middle; +} + +.setting-radio { + display: block; + line-height: 18px; +} + +.setting-radio__label { + margin-bottom: 0; +} + +.column-settings__row legend { + color: $darker-text-color; + cursor: default; + display: block; + font-weight: 500; + margin-top: 10px; +} + +.setting-radio__input { + vertical-align: middle; +} + +.setting-meta__label { + float: right; +} + +@keyframes heartbeat { + from { + transform: scale(1); + transform-origin: center center; + animation-timing-function: ease-out; + } + + 10% { + transform: scale(0.91); + animation-timing-function: ease-in; + } + + 17% { + transform: scale(0.98); + animation-timing-function: ease-out; + } + + 33% { + transform: scale(0.87); + animation-timing-function: ease-in; + } + + 45% { + transform: scale(1); + animation-timing-function: ease-out; + } +} + +.pulse-loading { + animation: heartbeat 1.5s ease-in-out infinite both; +} + +.upload-area { + align-items: center; + background: rgba($base-overlay-background, 0.8); + display: flex; + height: 100%; + justify-content: center; + left: 0; + opacity: 0; + position: absolute; + top: 0; + visibility: hidden; + width: 100%; + z-index: 2000; + + * { + pointer-events: none; + } +} + +.upload-area__drop { + width: 320px; + height: 160px; + display: flex; + box-sizing: border-box; + position: relative; + padding: 8px; +} + +.upload-area__background { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 5px rgba($base-shadow-color, 0.2); +} + +.upload-area__content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: $secondary-text-color; + font-size: 18px; + font-weight: 500; + border: 2px dashed $ui-base-lighter-color; + border-radius: 4px; +} + +.dropdown--active .emoji-button img { + opacity: 1; + filter: none; +} + +.loading-bar { + background-color: $ui-highlight-color; + height: 3px; + position: absolute; + top: 0; + left: 0; +} + +.icon-badge-wrapper { + position: relative; +} + +.icon-badge { + position: absolute; + display: block; + right: -.25em; + top: -.25em; + background-color: $ui-highlight-color; + border-radius: 50%; + font-size: 75%; + width: 1em; + height: 1em; +} + +::-webkit-scrollbar-thumb { + border-radius: 0; +} + +noscript { + text-align: center; + + img { + width: 200px; + opacity: 0.5; + animation: flicker 4s infinite; + } + + div { + font-size: 14px; + margin: 30px auto; + color: $secondary-text-color; + max-width: 400px; + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + } +} + +@keyframes flicker { + 0% { opacity: 1; } + 30% { opacity: 0.75; } + 100% { opacity: 1; } +} + +@import 'boost'; +@import 'accounts'; +@import 'domains'; +@import 'status'; +@import 'modal'; +@import 'composer'; +@import 'columns'; +@import 'regeneration_indicator'; +@import 'search'; +@import 'emoji'; +@import 'doodle'; +@import 'drawer'; +@import 'media'; +@import 'sensitive'; +@import 'lists'; +@import 'emoji_picker'; +@import 'local_settings'; +@import 'error_boundary'; diff --git a/app/javascript/flavours/glitch/styles/components/lists.scss b/app/javascript/flavours/glitch/styles/components/lists.scss new file mode 100644 index 000000000..d00a1941b --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/lists.scss @@ -0,0 +1,94 @@ +.list-editor { + background: $ui-base-color; + flex-direction: column; + border-radius: 8px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + width: 380px; + overflow: hidden; + + @media screen and (max-width: 420px) { + width: 90%; + } + + h4 { + padding: 15px 0; + background: lighten($ui-base-color, 13%); + font-weight: 500; + font-size: 16px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .drawer__pager { + height: 50vh; + } + + .drawer__inner { + border-radius: 0 0 8px 8px; + + &.backdrop { + width: calc(100% - 60px); + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 0 0 0 8px; + } + } + + &__accounts { + overflow-y: auto; + } + + .account__display-name { + &:hover strong { + text-decoration: none; + } + } + + .account__avatar { + cursor: default; + } + + .search { + margin-bottom: 0; + } +} + +.list-adder { + background: $ui-base-color; + flex-direction: column; + border-radius: 8px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + width: 380px; + overflow: hidden; + + @media screen and (max-width: 420px) { + width: 90%; + } + + &__account { + background: lighten($ui-base-color, 13%); + } + + &__lists { + background: lighten($ui-base-color, 13%); + height: 50vh; + border-radius: 0 0 8px 8px; + overflow-y: auto; + } + + .list { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + .list__wrapper { + display: flex; + } + + .list__display-name { + flex: 1 1 auto; + overflow: hidden; + text-decoration: none; + font-size: 16px; + padding: 10px; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/local_settings.scss b/app/javascript/flavours/glitch/styles/components/local_settings.scss new file mode 100644 index 000000000..0b7a74575 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/local_settings.scss @@ -0,0 +1,122 @@ +.glitch.local-settings { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $inverted-text-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label, legend { + display: block; + font-size: 14px; + } + + .boolean label, .radio_buttons label { + position: relative; + padding-left: 28px; + padding-top: 3px; + + input { + position: absolute; + left: 0; + top: 0; + } + } + + span.hint { + display: block; + color: $lighter-text-color; + } + + 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; + } +} + +.glitch.local-settings__navigation__item { + display: block; + padding: 15px 20px; + color: inherit; + background: lighten($ui-secondary-color, 8%); + border-bottom: 1px $ui-secondary-color solid; + cursor: pointer; + text-decoration: none; + outline: none; + transition: background .3s; + + .text-icon-button { + color: inherit; + transition: unset; + } + + &: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; + } +} + +.glitch.local-settings__navigation { + background: lighten($ui-secondary-color, 8%); + width: 212px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; +} + +.glitch.local-settings__page { + display: block; + flex: auto; + padding: 15px 20px 15px 20px; + width: 360px; + overflow-y: auto; +} + +.glitch.local-settings__page__item { + margin-bottom: 2px; +} + +.glitch.local-settings__page__item.string, +.glitch.local-settings__page__item.radio_buttons { + margin-top: 10px; + margin-bottom: 10px; +} + +@media screen and (max-width: 630px) { + .glitch.local-settings__navigation { + width: 40px; + flex-shrink: 0; + } + + .glitch.local-settings__navigation__item { + padding: 10px; + + span:last-of-type { + display: none; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss new file mode 100644 index 000000000..e5927057e --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -0,0 +1,654 @@ +.video-error-cover { + align-items: center; + background: $base-overlay-background; + color: $primary-text-color; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + margin-top: 8px; + position: relative; + text-align: center; + z-index: 100; +} + +.media-spoiler { + background: $base-overlay-background; + color: $darker-text-color; + border: 0; + width: 100%; + height: 100%; + + &:hover, + &:active, + &:focus { + color: lighten($darker-text-color, 8%); + } + + .status__content > & { + margin-top: 15px; // Add margin when used bare for NSFW video player + } + @include fullwidth-gallery; +} + +.media-spoiler__warning { + display: block; + font-size: 14px; +} + +.media-spoiler__trigger { + display: block; + font-size: 11px; + font-weight: 500; +} + +.media-gallery__gifv__label { + display: block; + position: absolute; + color: $primary-text-color; + background: rgba($base-overlay-background, 0.5); + bottom: 6px; + left: 6px; + padding: 2px 6px; + border-radius: 2px; + font-size: 11px; + font-weight: 600; + z-index: 1; + pointer-events: none; + opacity: 0.9; + transition: opacity 0.1s ease; + line-height: 18px; +} + +.media-gallery__gifv { + &.autoplay { + .media-gallery__gifv__label { + display: none; + } + } + + &:hover { + .media-gallery__gifv__label { + opacity: 1; + } + } +} + +.media-gallery { + box-sizing: border-box; + margin-top: 8px; + overflow: hidden; + border-radius: 4px; + position: relative; + width: 100%; + height: 110px; + + @include fullwidth-gallery; +} + +.media-gallery__item { + border: none; + box-sizing: border-box; + display: block; + float: left; + position: relative; + border-radius: 4px; + overflow: hidden; + + .full-width & { + border-radius: 0; + } + + &.standalone { + .media-gallery__item-gifv-thumbnail { + transform: none; + top: 0; + } + } + + &.letterbox { + background: $base-shadow-color; + } +} + +.media-gallery__item-thumbnail { + cursor: zoom-in; + display: block; + text-decoration: none; + color: $secondary-text-color; + line-height: 0; + position: relative; + z-index: 1; + + &, + img { + height: 100%; + width: 100%; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } + } +} + +.media-gallery__preview { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + +.media-gallery__gifv { + height: 100%; + overflow: hidden; + position: relative; + width: 100%; + display: flex; + justify-content: center; +} + +.media-gallery__item-gifv-thumbnail { + cursor: zoom-in; + height: 100%; + width: 100%; + position: relative; + z-index: 1; + object-fit: contain; + user-select: none; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } +} + +.media-gallery__item-thumbnail-label { + clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ + clip: rect(1px, 1px, 1px, 1px); + overflow: hidden; + position: absolute; +} + +.video-modal { + max-width: 100vw; + max-height: 100vh; + position: relative; +} + +.media-modal { + width: 100%; + height: 100%; + position: relative; + + .extended-video-player { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + video { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + } + } +} + +.media-modal__closer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.media-modal__navigation { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + transition: opacity 0.3s linear; + will-change: opacity; + + * { + pointer-events: auto; + } + + &.media-modal__navigation--hidden { + opacity: 0; + + * { + pointer-events: none; + } + } +} + +.media-modal__nav { + background: rgba($base-overlay-background, 0.5); + box-sizing: border-box; + border: 0; + color: $primary-text-color; + cursor: pointer; + display: flex; + align-items: center; + font-size: 24px; + height: 20vmax; + margin: auto 0; + padding: 30px 15px; + position: absolute; + top: 0; + bottom: 0; +} + +.media-modal__nav--left { + left: 0; +} + +.media-modal__nav--right { + right: 0; +} + +.media-modal__pagination { + width: 100%; + text-align: center; + position: absolute; + left: 0; + bottom: 20px; + pointer-events: none; +} + +.media-modal__meta { + text-align: center; + position: absolute; + left: 0; + bottom: 20px; + width: 100%; + pointer-events: none; + + &--shifted { + bottom: 62px; + } + + a { + text-decoration: none; + font-weight: 500; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + +.media-modal__page-dot { + display: inline-block; +} + +.media-modal__button { + background-color: $white; + height: 12px; + width: 12px; + border-radius: 6px; + margin: 10px; + padding: 0; + border: 0; + font-size: 0; +} + +.media-modal__button--active { + background-color: $ui-highlight-color; +} + +.media-modal__close { + position: absolute; + right: 8px; + top: 8px; + z-index: 100; +} + +.detailed, +.fullscreen { + .video-player__volume__current, + .video-player__volume::before { + bottom: 27px; + } + + .video-player__volume__handle { + bottom: 23px; + } + +} + +.video-player { + overflow: hidden; + position: relative; + background: $base-shadow-color; + max-width: 100%; + + &:focus { + outline: 0; + } + + .detailed-status & { + width: 100%; + height: 100%; + } + + @include fullwidth-gallery; + + video { + max-width: 100vw; + max-height: 80vh; + z-index: 1; + position: relative; + } + + &.fullscreen { + width: 100% !important; + height: 100% !important; + margin: 0; + + video { + max-width: 100% !important; + max-height: 100% !important; + width: 100% !important; + height: 100% !important; + } + } + + &.inline { + video { + object-fit: contain; + position: relative; + top: 50%; + transform: translateY(-50%); + } + } + + &__controls { + position: absolute; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + box-sizing: border-box; + background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent); + padding: 0 15px; + opacity: 0; + transition: opacity .1s ease; + + &.active { + opacity: 1; + } + } + + &.inactive { + video, + .video-player__controls { + visibility: hidden; + } + } + + &__spoiler { + display: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 4; + border: 0; + background: $base-shadow-color; + color: $darker-text-color; + transition: none; + pointer-events: none; + + &.active { + display: block; + pointer-events: auto; + + &:hover, + &:active, + &:focus { + color: lighten($darker-text-color, 7%); + } + } + + &__title { + display: block; + font-size: 14px; + } + + &__subtitle { + display: block; + font-size: 11px; + font-weight: 500; + } + } + + &__buttons-bar { + display: flex; + justify-content: space-between; + padding-bottom: 10px; + } + + &__buttons { + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.left { + button { + padding-left: 0; + } + } + + &.right { + button { + padding-right: 0; + } + } + + button { + background: transparent; + padding: 2px 10px; + font-size: 16px; + border: 0; + color: rgba($white, 0.75); + + &:active, + &:hover, + &:focus { + color: $white; + } + } + } + + &__time-sep, + &__time-total, + &__time-current { + font-size: 14px; + font-weight: 500; + } + + &__time-current { + color: $white; + margin-left: 60px; + } + + &__time-sep { + display: inline-block; + margin: 0 6px; + } + + &__time-sep, + &__time-total { + color: $white; + } + + &__volume { + cursor: pointer; + height: 24px; + display: inline; + + &::before { + content: ""; + width: 50px; + background: rgba($white, 0.35); + border-radius: 4px; + display: block; + position: absolute; + height: 4px; + left: 70px; + bottom: 20px; + } + + &__current { + display: block; + position: absolute; + height: 4px; + border-radius: 4px; + left: 70px; + bottom: 20px; + background: lighten($ui-highlight-color, 8%); + } + + &__handle { + position: absolute; + z-index: 3; + border-radius: 50%; + width: 12px; + height: 12px; + bottom: 16px; + left: 70px; + transition: opacity .1s ease; + background: lighten($ui-highlight-color, 8%); + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + pointer-events: none; + } + } + + &__link { + padding: 2px 10px; + + a { + text-decoration: none; + font-size: 14px; + font-weight: 500; + color: $white; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + } + + &__seek { + cursor: pointer; + height: 24px; + position: relative; + + &::before { + content: ""; + width: 100%; + background: rgba($white, 0.35); + border-radius: 4px; + display: block; + position: absolute; + height: 4px; + top: 10px; + } + + &__progress, + &__buffer { + display: block; + position: absolute; + height: 4px; + border-radius: 4px; + top: 10px; + background: lighten($ui-highlight-color, 8%); + } + + &__buffer { + background: rgba($white, 0.2); + } + + &__handle { + position: absolute; + z-index: 3; + opacity: 0; + border-radius: 50%; + width: 12px; + height: 12px; + top: 6px; + margin-left: -6px; + transition: opacity .1s ease; + background: lighten($ui-highlight-color, 8%); + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + pointer-events: none; + + &.active { + opacity: 1; + } + } + + &:hover { + .video-player__seek__handle { + opacity: 1; + } + } + } + +&.detailed, +&.fullscreen { + .video-player__buttons { + button { + padding-top: 10px; + padding-bottom: 10px; + } + } +} +} + +.media-spoiler-video { + background-size: cover; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + margin-top: 8px; + position: relative; + + @include fullwidth-gallery; + + border: 0; + display: block; +} + +.media-spoiler-video-play-icon { + border-radius: 100px; + color: rgba($primary-text-color, 0.8); + font-size: 36px; + left: 50%; + padding: 5px; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); +} diff --git a/app/javascript/flavours/glitch/styles/components/metadata.scss b/app/javascript/flavours/glitch/styles/components/metadata.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/metadata.scss diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss new file mode 100644 index 000000000..65b2e75f0 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -0,0 +1,822 @@ +.modal-container--preloader { + background: lighten($ui-base-color, 8%); +} + +.modal-root { + position: relative; + transition: opacity 0.3s linear; + will-change: opacity; + z-index: 9999; +} + +.modal-root__overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba($base-overlay-background, 0.7); +} + +.modal-root__container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + align-content: space-around; + z-index: 9999; + pointer-events: none; + user-select: none; +} + +.modal-root__modal { + pointer-events: auto; + display: flex; + z-index: 9999; +} + +.onboarding-modal, +.error-modal, +.embed-modal { + background: $ui-secondary-color; + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.onboarding-modal__pager { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 470px; + + .react-swipeable-view-container > div { + width: 100%; + height: 100%; + box-sizing: border-box; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + user-select: text; + } +} + +.error-modal__body { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 420px; + position: relative; + + & > div { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + opacity: 0; + user-select: text; + } +} + +.error-modal__body { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +@media screen and (max-width: 550px) { + .onboarding-modal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .onboarding-modal__pager { + width: 100%; + height: auto; + max-width: none; + max-height: none; + flex: 1 1 auto; + } +} + +.onboarding-modal__paginator, +.error-modal__footer { + flex: 0 0 auto; + background: darken($ui-secondary-color, 8%); + display: flex; + padding: 25px; + + & > div { + min-width: 33px; + } + + .onboarding-modal__nav, + .error-modal__nav { + color: $lighter-text-color; + border: 0; + font-size: 14px; + font-weight: 500; + padding: 10px 25px; + line-height: inherit; + height: auto; + margin: -10px; + border-radius: 4px; + background-color: transparent; + + &:hover, + &:focus, + &:active { + color: darken($lighter-text-color, 4%); + background-color: darken($ui-secondary-color, 16%); + } + + &.onboarding-modal__done, + &.onboarding-modal__next { + color: $inverted-text-color; + + &:hover, + &:focus, + &:active { + color: lighten($inverted-text-color, 4%); + } + } + } +} + +.error-modal__footer { + justify-content: center; +} + +.onboarding-modal__dots { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-modal__dot { + width: 14px; + height: 14px; + border-radius: 14px; + background: darken($ui-secondary-color, 16%); + margin: 0 3px; + cursor: pointer; + + &:hover { + background: darken($ui-secondary-color, 18%); + } + + &.active { + cursor: default; + background: darken($ui-secondary-color, 24%); + } +} + +.onboarding-modal__page__wrapper { + pointer-events: none; + padding: 25px; + padding-bottom: 0; + + &.onboarding-modal__page__wrapper--active { + pointer-events: auto; + } +} + +.onboarding-modal__page { + cursor: default; + line-height: 21px; + + h1 { + font-size: 18px; + font-weight: 500; + color: $inverted-text-color; + margin-bottom: 20px; + } + + a { + color: $highlight-text-color; + + &:hover, + &:focus, + &:active { + color: lighten($highlight-text-color, 4%); + } + } + + .navigation-bar a { + color: inherit; + } + + p { + font-size: 16px; + color: $lighter-text-color; + margin-top: 10px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + background: $ui-base-color; + color: $secondary-text-color; + border-radius: 4px; + font-size: 14px; + padding: 3px 6px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + } +} + +.onboarding-modal__page__wrapper-0 { + background: url('~images/elephant_ui_greeting.svg') no-repeat left bottom / auto 250px; + height: 100%; + padding: 0; +} + +.onboarding-modal__page-one { + &__lead { + padding: 65px; + padding-top: 45px; + padding-bottom: 0; + margin-bottom: 10px; + + h1 { + font-size: 26px; + line-height: 36px; + margin-bottom: 8px; + } + + p { + margin-bottom: 0; + } + } + + &__extra { + padding-right: 65px; + padding-left: 185px; + text-align: center; + } +} + +.display-case { + text-align: center; + font-size: 15px; + margin-bottom: 15px; + + &__label { + font-weight: 500; + color: $inverted-text-color; + margin-bottom: 5px; + text-transform: uppercase; + font-size: 12px; + } + + &__case { + background: $ui-base-color; + color: $secondary-text-color; + font-weight: 500; + padding: 10px; + border-radius: 4px; + } +} + +.onboarding-modal__page-two, +.onboarding-modal__page-three, +.onboarding-modal__page-four, +.onboarding-modal__page-five { + p { + text-align: left; + } + + .figure { + background: darken($ui-base-color, 8%); + color: $secondary-text-color; + margin-bottom: 20px; + border-radius: 4px; + padding: 10px; + text-align: center; + font-size: 14px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3); + + .onboarding-modal__image { + border-radius: 4px; + margin-bottom: 10px; + } + + &.non-interactive { + pointer-events: none; + text-align: left; + } + } +} + +.onboarding-modal__page-four__columns { + .row { + display: flex; + margin-bottom: 20px; + + & > div { + flex: 1 1 0; + margin: 0 10px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + p { + text-align: center; + } + } + + &:last-child { + margin-bottom: 0; + } + } + + .column-header { + color: $primary-text-color; + } +} + +@media screen and (max-width: 320px) and (max-height: 600px) { + .onboarding-modal__page p { + font-size: 14px; + line-height: 20px; + } + + .onboarding-modal__page-two .figure, + .onboarding-modal__page-three .figure, + .onboarding-modal__page-four .figure, + .onboarding-modal__page-five .figure { + font-size: 12px; + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .row { + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .column-header { + padding: 5px; + font-size: 12px; + } +} + +.onboard-sliders { + display: inline-block; + max-width: 30px; + max-height: auto; + margin-left: 10px; +} + +.boost-modal, +.favourite-modal, +.confirmation-modal, +.report-modal, +.actions-modal, +.mute-modal { + background: lighten($ui-secondary-color, 8%); + color: $inverted-text-color; + border-radius: 8px; + overflow: hidden; + max-width: 90vw; + width: 480px; + position: relative; + flex-direction: column; + + .status__relative-time { + color: $dark-text-color; + float: right; + font-size: 14px; + width: auto; + margin: initial; + padding: initial; + } + + .status__display-name { + display: flex; + } + + .status__avatar { + height: 48px; + width: 48px; + } + + .status__content__spoiler-link { + color: lighten($secondary-text-color, 8%); + } +} + +.actions-modal { + .status { + background: $white; + border-bottom-color: $ui-secondary-color; + padding-top: 10px; + padding-bottom: 10px; + } + + .dropdown-menu__separator { + border-bottom-color: $ui-secondary-color; + } +} + +.boost-modal__container, +.favourite-modal__container { + overflow-x: scroll; + padding: 10px; + + .status { + user-select: text; + border-bottom: 0; + } +} + +.boost-modal__action-bar, +.favourite-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar { + display: flex; + justify-content: space-between; + background: $ui-secondary-color; + padding: 10px; + line-height: 36px; + + & > div { + flex: 1 1 auto; + text-align: right; + color: $lighter-text-color; + padding-right: 10px; + } + + .button { + flex: 0 0 auto; + } +} + +.boost-modal__status-header, +.favourite-modal__status-header { + font-size: 15px; +} + +.boost-modal__status-time, +.favourite-modal__status-time { + float: right; + font-size: 14px; +} + +.mute-modal { + line-height: 24px; +} + +.mute-modal .react-toggle { + vertical-align: middle; +} + +.report-modal { + width: 90vw; + max-width: 700px; +} + +.report-modal__container { + display: flex; + border-top: 1px solid $ui-secondary-color; + + @media screen and (max-width: 480px) { + flex-wrap: wrap; + overflow-y: auto; + } +} + +.report-modal__statuses, +.report-modal__comment { + box-sizing: border-box; + width: 50%; + + @media screen and (max-width: 480px) { + width: 100%; + } +} + +.report-modal__statuses { + flex: 1 1 auto; + min-height: 20vh; + max-height: 80vh; + overflow-y: auto; + overflow-x: hidden; + + .status__content a { + color: $highlight-text-color; + } + + @media screen and (max-width: 480px) { + max-height: 10vh; + } +} + +.report-modal__comment { + padding: 20px; + border-right: 1px solid $ui-secondary-color; + max-width: 320px; + + p { + font-size: 14px; + line-height: 20px; + margin-bottom: 20px; + } + + .setting-text { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $inverted-text-color; + background: $white; + padding: 10px; + font-family: inherit; + font-size: 14px; + resize: vertical; + border: 0; + outline: 0; + border-radius: 4px; + border: 1px solid $ui-secondary-color; + margin-bottom: 20px; + + &:focus { + border: 1px solid darken($ui-secondary-color, 8%); + } + } + + .setting-toggle { + margin-top: 20px; + margin-bottom: 24px; + + &__label { + color: $inverted-text-color; + font-size: 14px; + } + } + + @media screen and (max-width: 480px) { + padding: 10px; + max-width: 100%; + order: 2; + + .setting-toggle { + margin-bottom: 4px; + } + } +} + +.report-modal__target { + padding: 20px; + + .media-modal__close { + top: 19px; + right: 15px; + } +} + +.actions-modal { + .status { + overflow-y: auto; + max-height: 300px; + } + + strong { + display: block; + font-weight: 500; + } + + max-height: 80vh; + max-width: 80vw; + + .actions-modal__item-label { + font-weight: 500; + } + + ul { + overflow-y: auto; + flex-shrink: 0; + max-height: 80vh; + + &.with-status { + max-height: calc(80vh - 75px); + } + + li:empty { + margin: 0; + } + + li:not(:empty) { + a { + color: $inverted-text-color; + display: flex; + padding: 12px 16px; + font-size: 15px; + align-items: center; + text-decoration: none; + + &, + button { + transition: none; + } + + &.active, + &:hover, + &:active, + &:focus { + &, + button { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + & > .react-toggle, + & > .icon, + button:first-child { + margin-right: 10px; + } + } + } + } +} + +.confirmation-modal__action-bar, +.mute-modal__action-bar { + .confirmation-modal__secondary-button, + .confirmation-modal__cancel-button, + .mute-modal__cancel-button { + background-color: transparent; + color: $lighter-text-color; + font-size: 14px; + font-weight: 500; + + &:hover, + &:focus, + &:active { + color: darken($lighter-text-color, 4%); + } + } + + .confirmation-modal__secondary-button { + flex-shrink: 1; + } +} + +.confirmation-modal__do_not_ask_again { + padding-left: 20px; + padding-right: 20px; + padding-bottom: 10px; + + font-size: 14px; + + label, input { + vertical-align: middle; + } +} + +.confirmation-modal__container, +.mute-modal__container, +.report-modal__target { + padding: 30px; + font-size: 16px; + text-align: center; + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } +} + +.embed-modal { + max-width: 80vw; + max-height: 80vh; + + h4 { + padding: 30px; + font-weight: 500; + font-size: 16px; + text-align: center; + } + + .embed-modal__container { + padding: 10px; + + .hint { + margin-bottom: 15px; + } + + .embed-modal__html { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $primary-text-color; + font-size: 14px; + margin: 0; + margin-bottom: 15px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + .embed-modal__iframe { + width: 400px; + max-width: 100%; + overflow: hidden; + border: 0; + } + } +} + +.focal-point { + position: relative; + cursor: pointer; + overflow: hidden; + + &.dragging { + cursor: move; + } + + img { + max-width: 80vw; + max-height: 80vh; + width: auto; + height: auto; + margin: auto; + } + + &__reticle { + position: absolute; + width: 100px; + height: 100px; + transform: translate(-50%, -50%); + background: url('~images/reticle.png') no-repeat 0 0; + border-radius: 50%; + box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35); + } + + &__overlay { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss new file mode 100644 index 000000000..178df6652 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss @@ -0,0 +1,53 @@ +.regeneration-indicator { + text-align: center; + font-size: 16px; + font-weight: 500; + color: $dark-text-color; + background: $ui-base-color; + cursor: default; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + padding: 20px; + + & > div { + width: 100%; + background: transparent; + padding-top: 0; + } + + &__figure { + background: url('~flavours/glitch/images/elephant_ui_working.svg') no-repeat center 0; + width: 100%; + height: 160px; + background-size: contain; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &.missing-indicator { + padding-top: 20px + 48px; + + .regeneration-indicator__figure { + background-image: url('~flavours/glitch/images/elephant_ui_disappointed.svg'); + } + } + + &__label { + margin-top: 200px; + + strong { + display: block; + margin-bottom: 10px; + color: $dark-text-color; + } + + span { + font-size: 15px; + font-weight: 400; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss new file mode 100644 index 000000000..f59ef019e --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/search.scss @@ -0,0 +1,144 @@ +.search { + position: relative; +} + +.search__input { + display: block; + padding: 10px; + padding-right: 30px; + @include search-input(); +} + +.search__icon { + .fa { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + display: inline-block; + opacity: 0; + transition: all 100ms linear; + transition-property: transform, opacity; + font-size: 18px; + width: 18px; + height: 18px; + color: $secondary-text-color; + cursor: default; + pointer-events: none; + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-search { + transform: rotate(90deg); + + &.active { + pointer-events: none; + transform: rotate(0deg); + } + } + + .fa-times-circle { + top: 11px; + transform: rotate(0deg); + cursor: pointer; + + &.active { + transform: rotate(90deg); + } + + &:hover { + color: $primary-text-color; + } + } +} + +.search-results__header { + color: $dark-text-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 4%); + padding: 15px 10px; + font-size: 14px; + font-weight: 500; +} + +.trends { + &__header { + color: $dark-text-color; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 4%); + font-weight: 500; + padding: 15px; + font-size: 16px; + cursor: default; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + &__item { + display: flex; + align-items: center; + padding: 15px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__name { + flex: 1 1 auto; + color: $dark-text-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + strong { + font-weight: 500; + } + + a { + color: $darker-text-color; + text-decoration: none; + font-size: 14px; + font-weight: 500; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover, + &:focus, + &:active { + span { + text-decoration: underline; + } + } + } + } + + &__current { + flex: 0 0 auto; + width: 100px; + font-size: 24px; + line-height: 36px; + font-weight: 500; + text-align: center; + color: $secondary-text-color; + } + + &__sparkline { + flex: 0 0 auto; + width: 50px; + + path { + stroke: lighten($highlight-text-color, 6%) !important; + } + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/sensitive.scss b/app/javascript/flavours/glitch/styles/components/sensitive.scss new file mode 100644 index 000000000..67b01c886 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/sensitive.scss @@ -0,0 +1,24 @@ +.sensitive-info { + display: flex; + flex-direction: row; + align-items: center; + position: absolute; + top: 4px; + left: 4px; + z-index: 100; +} + +.sensitive-marker { + margin: 0 3px; + border-radius: 2px; + padding: 2px 6px; + color: rgba($primary-text-color, 0.8); + background: rgba($base-overlay-background, 0.5); + font-size: 12px; + line-height: 18px; + text-transform: uppercase; + opacity: .9; + transition: opacity .1s ease; + + .media-gallery:hover & { opacity: 1 } +} diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss new file mode 100644 index 000000000..fb031258f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -0,0 +1,990 @@ +.status__content--with-action { + cursor: pointer; +} + +.status__content { + position: relative; + margin: 10px 0; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + overflow: visible; + padding-top: 5px; + + &:focus { + outline: 0; + } + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p, pre, blockquote { + margin-bottom: 20px; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0; + } + } + + .status__content__text { + h1, h2, h3, h4, h5 { + margin-top: 20px; + margin-bottom: 20px; + } + + h1, h2 { + font-weight: 700; + font-size: 18px; + } + + h2 { + font-size: 16px; + } + + h3, h4, h5 { + font-weight: 500; + } + + blockquote { + padding-left: 10px; + border-left: 3px solid $darker-text-color; + color: $darker-text-color; + white-space: normal; + + p:last-child { + margin-bottom: 0; + } + } + + b, strong { + font-weight: 700; + } + + em, i { + font-style: italic; + } + + sub { + font-size: smaller; + text-align: sub; + } + + ul, ol { + margin-left: 1em; + + p { + margin: 0; + } + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($dark-text-color, 7%); + } + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + .fa { + color: $dark-text-color; + } + } + + .status__content__spoiler { + display: none; + + &.status__content__spoiler--visible { + display: block; + } + } + + .status__content__spoiler-link { + background: lighten($ui-base-color, 30%); + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + } +} + +.status__content__spoiler-link { + display: inline-block; + border-radius: 2px; + background: lighten($ui-base-color, 30%); + border: none; + color: $inverted-text-color; + font-weight: 500; + font-size: 11px; + 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; + } +} + +.notif-cleaning { + .status, .notification-follow { + padding-right: ($dismiss-overlay-width + 0.5rem); + } +} + +.status__wrapper--filtered { + color: $dark-text-color; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + box-sizing: border-box; + width: 100%; + clear: both; + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.status__prepend-icon-wrapper { + float: left; + margin: 0 10px 0 -58px; + width: 48px; + text-align: right; +} + +.notification-follow { + position: relative; + + // same like Status + border-bottom: 1px solid lighten($ui-base-color, 8%); + + .account { + border-bottom: 0 none; + } +} + +.focusable { + &:focus { + outline: 0; + background: lighten($ui-base-color, 4%); + + .status.status-direct { + background: lighten($ui-base-color, 12%); + + &.muted { + background: transparent; + } + } + + .detailed-status, + .detailed-status__action-bar { + background: lighten($ui-base-color, 8%); + } + } +} + +.status { + padding: 10px 14px; + position: relative; + height: auto; + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: default; + + @supports (-ms-overflow-style: -ms-autohiding-scrollbar) { + // Add margin to avoid Edge auto-hiding scrollbar appearing over content. + // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px. + padding-right: 28px; // 12px + 16px + } + + @keyframes fade { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + + opacity: 1; + animation: fade 150ms linear; + + .video-player { + margin-top: 8px; + } + + &.status-direct { + background: lighten($ui-base-color, 8%); + } + + &.light { + .status__relative-time { + color: $lighter-text-color; + } + + .status__display-name { + color: $inverted-text-color; + } + + .display-name { + strong { + color: $inverted-text-color; + } + + span { + color: $lighter-text-color; + } + } + + .status__content { + color: $inverted-text-color; + + a { + color: $highlight-text-color; + } + + a.status__content__spoiler-link { + color: $primary-text-color; + background: $ui-primary-color; + + &:hover { + background: lighten($ui-primary-color, 8%); + } + } + } + } + + &.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)); + pointer-events: none; + content: ""; + } + + .display-name:hover .display-name__html { + text-decoration: none; + } + + .status__content { + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + padding-top: 0; + + &:after { + content: ""; + position: absolute; + top: 0; bottom: 0; + left: 0; right: 0; + background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1)); + pointer-events: none; + } + + a:hover { + text-decoration: none; + } + } + &:focus > .status__content:after { + background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1)); + } + &.status-direct> .status__content:after { + background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1)); + } + + .notification__message { + margin-bottom: 0; + } + + .status__info .notification__message > span { + white-space: nowrap; + } + } + + .notification__message { + margin: -10px 0px 10px 0; + } +} + +.notification-favourite { + .status.status-direct { + background: transparent; + + .icon-button.disabled { + color: lighten($action-button-color, 13%); + } + } +} + +.status__relative-time { + display: inline-block; + margin-left: auto; + padding-left: 18px; + width: 120px; + color: $dark-text-color; + font-size: 14px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status__display-name { + margin: 0 auto 0 0; + color: $dark-text-color; + overflow: hidden; +} + +.status__info__account .status__display-name { + display: block; + max-width: 100%; +} + +.status__info { + display: flex; + font-size: 15px; + + > span { + text-overflow: ellipsis; + overflow: hidden; + } + + .notification__message > span { + word-wrap: break-word; + } +} + +.status__info__icons { + margin-left: auto; + display: flex; + align-items: center; + height: 1em; + color: $action-button-color; + + .status__media-icon { + padding-left: 6px; + padding-right: 1px; + } + + .status__visibility-icon { + padding-left: 4px; + } +} + +.status__info__account { + display: flex; + align-items: center; +} + +.status-check-box { + border-bottom: 1px solid $ui-secondary-color; + display: flex; + + .status-check-box__status { + margin: 10px 0 10px 10px; + flex: 1; + + .media-gallery { + max-width: 250px; + } + + .status__content { + padding: 0; + white-space: normal; + } + + .video-player { + margin-top: 8px; + max-width: 250px; + } + + .media-gallery__item-thumbnail { + cursor: default; + } + } +} + +.status-check-box-toggle { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + padding: 10px; +} + +.status__prepend { + margin: -10px -10px 10px; + color: $dark-text-color; + padding: 8px 10px 0 68px; + font-size: 14px; + position: relative; + + .status__display-name strong { + color: $dark-text-color; + } + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.status__action-bar { + align-items: center; + display: flex; + margin-top: 8px; + + &__counter { + display: inline-flex; + margin-right: 11px; + align-items: center; + + .status__action-bar-button { + margin-right: 4px; + } + + &__label { + display: inline-block; + width: 14px; + font-size: 12px; + font-weight: 500; + color: $action-button-color; + } + } +} + +.status__action-bar-button { + margin-right: 18px; +} + +.status__action-bar-dropdown { + height: 23.15px; + width: 23.15px; +} + +.detailed-status__action-bar-dropdown { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.detailed-status { + background: lighten($ui-base-color, 4%); + padding: 14px 10px; + + &--flex { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + + .status__content, + .detailed-status__meta { + flex: 100%; + } + } + + .status__content { + font-size: 19px; + line-height: 24px; + + .emojione { + width: 24px; + height: 24px; + margin: -1px 0 0; + } + } + + .video-player { + margin-top: 8px; + } +} + +.detailed-status__meta { + margin-top: 15px; + color: $dark-text-color; + font-size: 14px; + line-height: 18px; +} + +.detailed-status__action-bar { + background: lighten($ui-base-color, 4%); + border-top: 1px solid lighten($ui-base-color, 8%); + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: row; + padding: 10px 0; +} + +.detailed-status__link { + color: inherit; + text-decoration: none; +} + +.detailed-status__favorites, +.detailed-status__reblogs { + display: inline-block; + font-weight: 500; + font-size: 12px; + margin-left: 6px; +} + +.status__display-name, +.status__relative-time, +.detailed-status__display-name, +.detailed-status__datetime, +.detailed-status__application, +.account__display-name { + text-decoration: none; +} + +.status__display-name, +.account__display-name { + strong { + color: $primary-text-color; + } +} + +.muted { + .emojione { + opacity: 0.5; + } +} + +.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; + text-overflow: ellipsis; +} + +.detailed-status__application, +.detailed-status__datetime { + color: inherit; +} + +.detailed-status__display-name { + color: $secondary-text-color; + display: block; + line-height: 24px; + margin-bottom: 15px; + overflow: hidden; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + strong { + font-size: 16px; + color: $primary-text-color; + } +} + +.detailed-status__display-avatar { + float: left; + margin-right: 10px; +} + +.status__avatar { + flex: none; + margin: 0 10px 0 0; + height: 48px; + width: 48px; +} + +.muted { + .status__content p, + .status__content a, + .status__content_text { + color: $dark-text-color; + } + + .status__display-name strong { + color: $dark-text-color; + } + + .status__avatar { + opacity: 0.5; + } + + a.status__content__spoiler-link { + background: $ui-base-lighter-color; + color: $inverted-text-color; + + &:hover { + background: lighten($ui-base-color, 29%); + text-decoration: none; + } + } +} + +.status__relative-time, +.detailed-status__datetime { + &:hover { + text-decoration: underline; + } +} + +.status-card { + display: flex; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + color: $dark-text-color; + margin-top: 14px; + text-decoration: none; + overflow: hidden; + + &__actions { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + display: flex; + justify-content: center; + align-items: center; + + & > div { + background: rgba($base-shadow-color, 0.6); + border-radius: 8px; + padding: 12px 9px; + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + } + + button, + a { + display: inline; + color: $secondary-text-color; + background: transparent; + border: 0; + padding: 0 8px; + text-decoration: none; + font-size: 18px; + line-height: 18px; + + &:hover, + &:active, + &:focus { + color: $primary-text-color; + } + } + + a { + font-size: 19px; + position: relative; + bottom: -1px; + } + + a .fa, a:hover .fa { + color: inherit; + } + } +} + +a.status-card { + cursor: pointer; + + &:hover { + background: lighten($ui-base-color, 8%); + } +} + +.status-card-photo { + cursor: zoom-in; + display: block; + text-decoration: none; + width: 100%; + height: auto; + margin: 0; +} + +.status-card-video { + iframe { + width: 100%; + height: 100%; + } +} + +.status-card__title { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: $darker-text-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} + +.status-card__content { + flex: 1 1 auto; + overflow: hidden; + padding: 14px 14px 14px 8px; +} + +.status-card__description { + color: $darker-text-color; +} + +.status-card__host { + display: block; + margin-top: 5px; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-card__image { + flex: 0 0 100px; + background: lighten($ui-base-color, 8%); + position: relative; + + & > .fa { + font-size: 21px; + position: absolute; + transform-origin: 50% 50%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} + +.status-card.horizontal { + display: block; + + .status-card__image { + width: 100%; + } + + .status-card__image-image { + border-radius: 4px 4px 0 0; + } + + .status-card__title { + white-space: inherit; + } +} + +.status-card.compact { + border-color: lighten($ui-base-color, 4%); + + &.interactive { + border: 0; + } + + .status-card__content { + padding: 8px; + padding-top: 10px; + } + + .status-card__title { + white-space: nowrap; + } + + .status-card__image { + flex: 0 0 60px; + } +} + +a.status-card.compact:hover { + background-color: lighten($ui-base-color, 4%); +} + +.status-card__image-image { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: cover; + background-size: cover; + background-position: center center; +} + +.status__video-player { + display: flex; + align-items: center; + background: $base-shadow-color; + box-sizing: border-box; + cursor: default; /* May not be needed */ + margin-top: 8px; + overflow: hidden; + position: relative; + + @include fullwidth-gallery; +} + +.status__video-player-video { + height: 100%; + object-fit: contain; + position: relative; + top: 50%; + transform: translateY(-50%); + width: 100%; + z-index: 1; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } +} + +.status__video-player-expand, +.status__video-player-mute { + color: $primary-text-color; + opacity: 0.8; + position: absolute; + right: 4px; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; +} + +.status__video-player-spoiler { + display: none; + color: $primary-text-color; + left: 4px; + position: absolute; + text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; + top: 4px; + z-index: 100; + + &.status__video-player-spoiler--visible { + display: block; + } +} + +.status__video-player-expand { + bottom: 4px; + z-index: 100; +} + +.status__video-player-mute { + top: 4px; + z-index: 5; +} + +.attachment-list { + display: flex; + font-size: 14px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + margin-top: 14px; + overflow: hidden; + + &__icon { + flex: 0 0 auto; + color: $dark-text-color; + padding: 8px 18px; + cursor: default; + border-right: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 26px; + + .fa { + display: block; + } + } + + &__list { + list-style: none; + padding: 4px 0; + padding-left: 8px; + display: flex; + flex-direction: column; + justify-content: center; + + li { + display: block; + padding: 4px 0; + } + + a { + text-decoration: none; + color: $dark-text-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + } + + &.compact { + border: 0; + margin-top: 4px; + + .attachment-list__list { + padding: 0; + display: block; + } + + .fa { + color: $dark-text-color; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss new file mode 100644 index 000000000..b27524739 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/containers.scss @@ -0,0 +1,819 @@ +.container-alt { + width: 700px; + margin: 0 auto; + margin-top: 40px; + + @media screen and (max-width: 740px) { + width: 100%; + margin: 0; + } +} + +.logo-container { + margin: 100px auto 50px; + + @media screen and (max-width: 500px) { + margin: 40px auto 0; + } + + h1 { + display: flex; + justify-content: center; + align-items: center; + + img { + height: 42px; + margin-right: 10px; + } + + a { + display: flex; + justify-content: center; + align-items: center; + color: $primary-text-color; + text-decoration: none; + outline: 0; + padding: 12px 16px; + line-height: 32px; + font-family: $font-display, sans-serif; + font-weight: 500; + font-size: 14px; + } + } +} + +.compose-standalone { + .compose-form { + width: 400px; + margin: 0 auto; + padding: 20px 0; + margin-top: 40px; + box-sizing: border-box; + + @media screen and (max-width: 400px) { + width: 100%; + margin-top: 0; + padding: 20px; + } + } +} + +.account-header { + width: 400px; + margin: 0 auto; + display: flex; + font-size: 13px; + line-height: 18px; + box-sizing: border-box; + padding: 20px 0; + padding-bottom: 0; + margin-bottom: -30px; + margin-top: 40px; + + @media screen and (max-width: 440px) { + width: 100%; + margin: 0; + margin-bottom: 10px; + padding: 20px; + padding-bottom: 0; + } + + .avatar { + width: 40px; + height: 40px; + @include avatar-size(40px); + margin-right: 8px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + @include avatar-radius(); + } + } + + .name { + flex: 1 1 auto; + color: $secondary-text-color; + width: calc(100% - 88px); + + .username { + display: block; + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .logout-link { + display: block; + font-size: 32px; + line-height: 40px; + margin-left: 8px; + } +} + +.grid-3 { + display: grid; + grid-gap: 10px; + grid-template-columns: 3fr 1fr; + grid-auto-columns: 25%; + grid-auto-rows: max-content; + + .column-0 { + grid-column: 1/3; + grid-row: 1; + } + + .column-1 { + grid-column: 1; + grid-row: 2; + } + + .column-2 { + grid-column: 2; + grid-row: 2; + } + + .column-3 { + grid-column: 1/3; + grid-row: 3; + } + + .landing-page__call-to-action { + min-height: 100%; + } + + @media screen and (max-width: 738px) { + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + .landing-page__call-to-action { + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + } + + .row__information-board { + width: 100%; + justify-content: center; + align-items: center; + } + + .row__mascot { + display: none; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + grid-gap: 0; + grid-template-columns: minmax(0, 100%); + + .column-0 { + grid-column: 1; + } + + .column-1 { + grid-column: 1; + grid-row: 3; + } + + .column-2 { + grid-column: 1; + grid-row: 2; + } + + .column-3 { + grid-column: 1; + grid-row: 4; + } + } +} + +.public-layout { + @media screen and (max-width: $no-gap-breakpoint) { + padding-top: 48px; + } + + .container { + max-width: 960px; + + @media screen and (max-width: $no-gap-breakpoint) { + padding: 0; + } + } + + .header { + background: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + height: 48px; + margin: 10px 0; + display: flex; + align-items: stretch; + justify-content: center; + flex-wrap: nowrap; + overflow: hidden; + + @media screen and (max-width: $no-gap-breakpoint) { + position: fixed; + width: 100%; + top: 0; + left: 0; + margin: 0; + border-radius: 0; + box-shadow: none; + z-index: 110; + } + + & > div { + flex: 1 1 33.3%; + min-height: 1px; + } + + .nav-left { + display: flex; + align-items: stretch; + justify-content: flex-start; + flex-wrap: nowrap; + } + + .nav-center { + display: flex; + align-items: stretch; + justify-content: center; + flex-wrap: nowrap; + } + + .nav-right { + display: flex; + align-items: stretch; + justify-content: flex-end; + flex-wrap: nowrap; + } + + .brand { + display: block; + padding: 15px; + + img { + display: block; + height: 18px; + width: auto; + position: relative; + bottom: -2px; + + @media screen and (max-width: $no-gap-breakpoint) { + height: 20px; + } + } + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 12%); + } + } + + .nav-link { + display: flex; + align-items: center; + padding: 0 1rem; + font-size: 12px; + font-weight: 500; + text-decoration: none; + color: $darker-text-color; + white-space: nowrap; + text-align: center; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + color: $primary-text-color; + } + + @media screen and (max-width: 550px) { + &.optional { + display: none; + } + } + } + + .nav-button { + background: lighten($ui-base-color, 16%); + margin: 8px; + margin-left: 0; + border-radius: 4px; + + &:hover, + &:focus, + &:active { + text-decoration: none; + background: lighten($ui-base-color, 20%); + } + } + } + + $no-columns-breakpoint: 600px; + + .grid { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr); + grid-auto-columns: 25%; + grid-auto-rows: max-content; + + .column-0 { + grid-row: 1; + grid-column: 1; + } + + .column-1 { + grid-row: 1; + grid-column: 2; + } + + @media screen and (max-width: $no-columns-breakpoint) { + grid-template-columns: 100%; + grid-gap: 0; + + .column-1 { + display: none; + } + } + } + + .public-account-header { + overflow: hidden; + margin-bottom: 10px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &.inactive { + opacity: 0.5; + + .public-account-header__image, + .avatar { + filter: grayscale(100%); + } + + .logo-button { + background-color: $secondary-text-color; + + svg path:last-child { + fill: $secondary-text-color; + } + } + } + + &__image { + border-radius: 4px 4px 0 0; + overflow: hidden; + height: 300px; + position: relative; + background: darken($ui-base-color, 12%); + + &::after { + content: ""; + display: block; + position: absolute; + width: 100%; + height: 100%; + box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15); + top: 0; + left: 0; + } + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + border-radius: 4px 4px 0 0; + } + + @media screen and (max-width: 600px) { + height: 200px; + } + } + + &--no-bar { + margin-bottom: 0; + + .public-account-header__image, + .public-account-header__image img { + border-radius: 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-radius: 0; + } + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin-bottom: 0; + box-shadow: none; + + &__image::after { + display: none; + } + + &__image, + &__image img { + border-radius: 0; + } + } + + &__bar { + position: relative; + margin-top: -80px; + display: flex; + justify-content: flex-start; + + &::before { + content: ""; + display: block; + background: lighten($ui-base-color, 4%); + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 60px; + border-radius: 0 0 4px 4px; + z-index: -1; + } + + .avatar { + display: block; + width: 120px; + height: 120px; + @include avatar-size(120px); + padding-left: 20px - 4px; + flex: 0 0 auto; + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + border-radius: 50%; + border: 4px solid lighten($ui-base-color, 4%); + background: darken($ui-base-color, 8%); + @include avatar-radius(); + } + } + + @media screen and (max-width: 600px) { + margin-top: 0; + background: lighten($ui-base-color, 4%); + border-radius: 0 0 4px 4px; + padding: 5px; + + &::before { + display: none; + } + + .avatar { + width: 48px; + height: 48px; + @include avatar-size(48px); + padding: 7px 0; + padding-left: 10px; + + img { + border: 0; + border-radius: 4px; + @include avatar-radius(); + } + + @media screen and (max-width: 360px) { + display: none; + } + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + border-radius: 0; + } + + @media screen and (max-width: $no-columns-breakpoint) { + flex-wrap: wrap; + } + } + + &__tabs { + flex: 1 1 auto; + margin-left: 20px; + + &__name { + padding-top: 20px; + padding-bottom: 8px; + + h1 { + font-size: 20px; + line-height: 18px * 1.5; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-shadow: 1px 1px 1px $base-shadow-color; + + small { + display: block; + font-size: 14px; + color: $primary-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + @media screen and (max-width: 600px) { + margin-left: 15px; + display: flex; + justify-content: space-between; + align-items: center; + + &__name { + padding-top: 0; + padding-bottom: 0; + + h1 { + font-size: 16px; + line-height: 24px; + text-shadow: none; + + small { + color: $darker-text-color; + } + } + } + } + + &__tabs { + display: flex; + justify-content: flex-start; + align-items: stretch; + height: 58px; + + .details-counters { + display: flex; + flex-direction: row; + min-width: 300px; + } + + @media screen and (max-width: $no-columns-breakpoint) { + .details-counters { + display: none; + } + } + + .counter { + width: 33.3%; + box-sizing: border-box; + flex: 0 0 auto; + color: $darker-text-color; + padding: 10px; + border-right: 1px solid lighten($ui-base-color, 4%); + cursor: default; + text-align: center; + position: relative; + + a { + display: block; + } + + &:last-child { + border-right: 0; + } + + &::after { + display: block; + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + border-bottom: 4px solid $ui-primary-color; + opacity: 0.5; + transition: all 400ms ease; + } + + &.active { + &::after { + border-bottom: 4px solid $highlight-text-color; + opacity: 1; + } + + &.inactive::after { + border-bottom-color: $secondary-text-color; + } + } + + &:hover { + &::after { + opacity: 1; + transition-duration: 100ms; + } + } + + a { + text-decoration: none; + color: inherit; + } + + .counter-label { + font-size: 12px; + display: block; + } + + .counter-number { + font-weight: 500; + font-size: 18px; + margin-bottom: 5px; + color: $primary-text-color; + font-family: $font-display, sans-serif; + } + } + + .spacer { + flex: 1 1 auto; + height: 1px; + } + + &__buttons { + padding: 7px 8px; + } + } + } + + &__extra { + display: none; + margin-top: 4px; + + .public-account-bio { + border-radius: 0; + box-shadow: none; + background: transparent; + margin: 0 -5px; + + .account__header__fields { + border-top: 1px solid lighten($ui-base-color, 12%); + } + + .roles { + display: none; + } + } + + &__links { + margin-top: -15px; + font-size: 14px; + color: $darker-text-color; + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + padding: 15px; + font-weight: 500; + + strong { + font-weight: 700; + color: $primary-text-color; + } + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + flex: 100%; + } + } + } + + .account__section-headline { + border-radius: 4px 4px 0 0; + + @media screen and (max-width: $no-gap-breakpoint) { + border-radius: 0; + } + } + + .detailed-status__meta { + margin-top: 25px; + } + + .public-account-bio { + background: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; + + @media screen and (max-width: $no-gap-breakpoint) { + box-shadow: none; + margin-bottom: 0; + border-radius: 0; + } + + .account__header__fields { + margin: 0; + border-top: 0; + + a { + color: lighten($ui-highlight-color, 8%); + } + + dl:first-child .verified { + border-radius: 0 4px 0 0; + } + + .verified a { + color: $valid-value-color; + } + } + + .account__header__content { + padding: 20px; + padding-bottom: 0; + color: $primary-text-color; + } + + &__extra, + .roles { + padding: 20px; + font-size: 14px; + color: $darker-text-color; + } + + .roles { + padding-bottom: 0; + } + } + + .static-icon-button { + color: $action-button-color; + font-size: 18px; + + & > span { + font-size: 14px; + font-weight: 500; + } + } + + .card-grid { + display: flex; + flex-wrap: wrap; + min-width: 100%; + margin: 0 -5px; + + & > div { + box-sizing: border-box; + flex: 1 0 auto; + width: 300px; + padding: 0 5px; + margin-bottom: 10px; + max-width: 33.333%; + + @media screen and (max-width: 900px) { + max-width: 50%; + } + + @media screen and (max-width: 600px) { + max-width: 100%; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin: 0; + border-top: 1px solid lighten($ui-base-color, 8%); + + & > div { + width: 100%; + padding: 0; + margin-bottom: 0; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + .card__bar { + background: $ui-base-color; + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 4%); + } + } + } + } + } +} diff --git a/app/javascript/flavours/glitch/styles/contrast.scss b/app/javascript/flavours/glitch/styles/contrast.scss new file mode 100644 index 000000000..4de31db9a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/contrast.scss @@ -0,0 +1,3 @@ +@import 'contrast/variables'; +@import 'index'; +@import 'contrast/diff'; diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss new file mode 100644 index 000000000..8429103b8 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss @@ -0,0 +1,69 @@ +// components.scss +.compose-form { + .compose-form__modifiers { + .compose-form__upload { + &-description { + input { + &::placeholder { + opacity: 1.0; + } + } + } + } + } +} + +.rich-formatting a, +.rich-formatting p a, +.rich-formatting li a, +.landing-page__short-description p a, +.status__content a, +.reply-indicator__content a { + color: lighten($ui-highlight-color, 12%); + text-decoration: underline; + + &.mention { + text-decoration: none; + } + + &.mention span { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + + &.status__content__spoiler-link { + color: $secondary-text-color; + text-decoration: none; + } +} + +.status__content__read-more-button { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } +} + +.getting-started__footer a { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } +} diff --git a/app/javascript/flavours/glitch/styles/contrast/variables.scss b/app/javascript/flavours/glitch/styles/contrast/variables.scss new file mode 100644 index 000000000..f6cadf029 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/contrast/variables.scss @@ -0,0 +1,24 @@ +// Dependent colors +$black: #000000; + +$classic-base-color: #282c37; +$classic-primary-color: #9baec8; +$classic-secondary-color: #d9e1e8; +$classic-highlight-color: #2b90d9; + +$ui-base-color: $classic-base-color !default; +$ui-primary-color: $classic-primary-color !default; +$ui-secondary-color: $classic-secondary-color !default; + +// Differences +$ui-highlight-color: #2b5fd9; + +$darker-text-color: lighten($ui-primary-color, 20%) !default; +$dark-text-color: lighten($ui-primary-color, 12%) !default; +$secondary-text-color: lighten($ui-secondary-color, 6%) !default; +$highlight-text-color: $classic-highlight-color !default; +$action-button-color: #8d9ac2; + +$inverted-text-color: $black !default; +$lighter-text-color: darken($ui-base-color,6%) !default; +$light-text-color: darken($ui-primary-color, 40%) !default; diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss new file mode 100644 index 000000000..e4564f062 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/dashboard.scss @@ -0,0 +1,76 @@ +.dashboard__counters { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + margin-bottom: 20px; + + & > div { + box-sizing: border-box; + flex: 0 0 33.333%; + padding: 0 5px; + margin-bottom: 10px; + + & > div, + & > a { + padding: 20px; + background: lighten($ui-base-color, 4%); + border-radius: 4px; + } + + & > a { + text-decoration: none; + color: inherit; + display: block; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 8%); + } + } + } + + &__num, + &__text { + text-align: center; + font-weight: 500; + font-size: 24px; + line-height: 21px; + color: $primary-text-color; + font-family: $font-display, sans-serif; + margin-bottom: 20px; + line-height: 30px; + } + + &__text { + font-size: 18px; + } + + &__label { + font-size: 14px; + color: $darker-text-color; + text-align: center; + font-weight: 500; + } +} + +.dashboard__widgets { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + + & > div { + flex: 0 0 33.333%; + margin-bottom: 20px; + + & > div { + padding: 0 5px; + } + } + + a:not(.name-tag) { + color: $ui-secondary-color; + font-weight: 500; + text-decoration: none; + } +} diff --git a/app/javascript/flavours/glitch/styles/footer.scss b/app/javascript/flavours/glitch/styles/footer.scss new file mode 100644 index 000000000..4d75477e0 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/footer.scss @@ -0,0 +1,140 @@ +.public-layout { + .footer { + text-align: left; + padding-top: 20px; + padding-bottom: 60px; + font-size: 12px; + color: lighten($ui-base-color, 34%); + + @media screen and (max-width: $no-gap-breakpoint) { + padding-left: 20px; + padding-right: 20px; + } + + .grid { + display: grid; + grid-gap: 10px; + grid-template-columns: 1fr 1fr 2fr 1fr 1fr; + + .column-0 { + grid-column: 1; + grid-row: 1; + min-width: 0; + } + + .column-1 { + grid-column: 2; + grid-row: 1; + min-width: 0; + } + + .column-2 { + grid-column: 3; + grid-row: 1; + min-width: 0; + text-align: center; + + h4 a { + color: lighten($ui-base-color, 34%); + } + } + + .column-3 { + grid-column: 4; + grid-row: 1; + min-width: 0; + } + + .column-4 { + grid-column: 5; + grid-row: 1; + min-width: 0; + } + + @media screen and (max-width: 690px) { + grid-template-columns: 1fr 2fr 1fr; + + .column-0, + .column-1 { + grid-column: 1; + } + + .column-1 { + grid-row: 2; + } + + .column-2 { + grid-column: 2; + } + + .column-3, + .column-4 { + grid-column: 3; + } + + .column-4 { + grid-row: 2; + } + } + + @media screen and (max-width: 600px) { + .column-1 { + display: block; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + .column-0, + .column-1, + .column-3, + .column-4 { + display: none; + } + } + } + + h4 { + text-transform: uppercase; + font-weight: 700; + margin-bottom: 8px; + color: $darker-text-color; + + a { + color: inherit; + text-decoration: none; + } + } + + ul a { + text-decoration: none; + color: lighten($ui-base-color, 34%); + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + + .brand { + svg { + display: block; + height: 36px; + width: auto; + margin: 0 auto; + + path { + fill: lighten($ui-base-color, 34%); + } + } + + &:hover, + &:focus, + &:active { + svg path { + fill: lighten($ui-base-color, 38%); + } + } + } + } +} diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss new file mode 100644 index 000000000..2b8d7a682 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -0,0 +1,912 @@ +$no-columns-breakpoint: 600px; + +code { + font-family: $font-monospace, monospace; + font-weight: 400; +} + +.form-container { + max-width: 400px; + padding: 20px; + margin: 0 auto; +} + +.simple_form { + .input { + margin-bottom: 15px; + overflow: hidden; + + &.hidden { + margin: 0; + } + + &.radio_buttons { + .radio { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + } + + .radio > label { + position: relative; + padding-left: 28px; + + input { + position: absolute; + top: -2px; + left: 0; + } + } + } + + &.boolean { + position: relative; + margin-bottom: 0; + + .label_input > label { + font-family: inherit; + font-size: 14px; + padding-top: 5px; + color: $primary-text-color; + display: block; + width: auto; + } + + .label_input, + .hint { + padding-left: 28px; + } + + .label_input__wrapper { + position: static; + } + + label.checkbox { + position: absolute; + top: 2px; + left: 0; + } + + label a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } + } + } + + .row { + display: flex; + margin: 0 -5px; + + .input { + box-sizing: border-box; + flex: 1 1 auto; + width: 50%; + padding: 0 5px; + } + } + + .hint { + color: $darker-text-color; + + a { + color: $highlight-text-color; + } + + code { + border-radius: 3px; + padding: 0.2em 0.4em; + background: darken($ui-base-color, 12%); + } + } + + span.hint { + display: block; + font-size: 12px; + margin-top: 4px; + } + + p.hint { + margin-bottom: 15px; + color: $darker-text-color; + + &.subtle-hint { + text-align: center; + font-size: 12px; + line-height: 18px; + margin-top: 15px; + margin-bottom: 0; + } + } + + .card { + margin-bottom: 15px; + } + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + .input.with_floating_label { + .label_input { + display: flex; + + & > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + min-width: 150px; + flex: 0 0 auto; + } + + input, + select { + flex: 1 1 auto; + } + } + + &.select .hint { + margin-top: 6px; + margin-left: 150px; + } + } + + .input.with_label { + .label_input > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + margin-bottom: 8px; + word-wrap: break-word; + font-weight: 500; + } + + .hint { + margin-top: 6px; + } + + ul { + flex: 390px; + } + } + + .input.with_block_label { + max-width: none; + + & > label { + font-family: inherit; + font-size: 16px; + color: $primary-text-color; + display: block; + font-weight: 500; + padding-top: 5px; + } + + .hint { + margin-bottom: 15px; + } + + ul { + columns: 2; + } + } + + .required abbr { + text-decoration: none; + color: lighten($error-value-color, 12%); + } + + .fields-group { + margin-bottom: 25px; + + .input:last-child { + margin-bottom: 0; + } + } + + .fields-row { + display: flex; + margin: 0 -10px; + padding-top: 5px; + margin-bottom: 25px; + + .input { + max-width: none; + } + + &__column { + box-sizing: border-box; + padding: 0 10px; + flex: 1 1 auto; + min-height: 1px; + + &-6 { + max-width: 50%; + } + } + + .fields-group:last-child, + .fields-row__column.fields-group { + margin-bottom: 0; + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + margin-bottom: 0; + + &__column { + max-width: none; + } + + .fields-group:last-child, + .fields-row__column.fields-group, + .fields-row__column { + margin-bottom: 25px; + } + } + } + + .input.radio_buttons .radio label { + margin-bottom: 5px; + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: block; + width: auto; + } + + .check_boxes { + .checkbox { + label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + display: inline-block; + width: auto; + position: relative; + padding-top: 5px; + padding-left: 25px; + flex: 1 1 auto; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 5px; + margin: 0; + } + } + } + + input[type=text], + input[type=number], + input[type=email], + input[type=password], + textarea { + box-sizing: border-box; + font-size: 16px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + background: darken($ui-base-color, 10%); + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + padding: 10px; + + &:invalid { + box-shadow: none; + } + + &:focus:invalid:not(:placeholder-shown) { + border-color: lighten($error-red, 12%); + } + + &:required:valid { + border-color: $valid-value-color; + } + + &:hover { + border-color: darken($ui-base-color, 20%); + } + + &:active, + &:focus { + border-color: $highlight-text-color; + background: darken($ui-base-color, 8%); + } + } + + .input.field_with_errors { + label { + color: lighten($error-red, 12%); + } + + input[type=text], + input[type=number], + input[type=email], + input[type=password], + textarea, + select { + border-color: lighten($error-red, 12%); + } + + .error { + display: block; + font-weight: 500; + color: lighten($error-red, 12%); + margin-top: 4px; + } + } + + .input.disabled { + opacity: 0.5; + } + + .actions { + margin-top: 30px; + display: flex; + + &.actions--top { + margin-top: 0; + margin-bottom: 30px; + } + } + + button, + .button, + .block-button { + display: block; + width: 100%; + border: 0; + border-radius: 4px; + background: $ui-highlight-color; + color: $primary-text-color; + font-size: 18px; + line-height: inherit; + height: auto; + padding: 10px; + text-transform: uppercase; + text-decoration: none; + text-align: center; + box-sizing: border-box; + cursor: pointer; + font-weight: 500; + outline: 0; + margin-bottom: 10px; + margin-right: 10px; + + &:last-child { + margin-right: 0; + } + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + + &:active, + &:focus { + background-color: darken($ui-highlight-color, 5%); + } + + &:disabled:hover { + background-color: $ui-primary-color; + } + + &.negative { + background: $error-value-color; + + &:hover { + background-color: lighten($error-value-color, 5%); + } + + &:active, + &:focus { + background-color: darken($error-value-color, 5%); + } + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 16px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + padding-left: 10px; + padding-right: 30px; + height: 41px; + } + + .label_input { + &__wrapper { + position: relative; + } + + &__append { + position: absolute; + right: 3px; + top: 1px; + padding: 10px; + padding-bottom: 9px; + font-size: 16px; + color: $dark-text-color; + font-family: inherit; + pointer-events: none; + cursor: default; + max-width: 140px; + white-space: nowrap; + overflow: hidden; + + &::after { + content: ''; + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 1px; + width: 5px; + background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%)); + } + } + } + + &__overlay-area { + position: relative; + + &__overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: rgba($ui-base-color, 0.65); + backdrop-filter: blur(2px); + border-radius: 4px; + + &__content { + text-align: center; + + &.rich-formatting { + &, + p { + color: $primary-text-color; + } + } + } + } + } +} + +.block-icon { + display: block; + margin: 0 auto; + margin-bottom: 10px; + font-size: 24px; +} + +.flash-message { + background: lighten($ui-base-color, 8%); + color: $darker-text-color; + border-radius: 4px; + padding: 15px 10px; + margin-bottom: 30px; + text-align: center; + + &.notice { + border: 1px solid rgba($valid-value-color, 0.5); + background: rgba($valid-value-color, 0.25); + color: $valid-value-color; + } + + &.alert { + border: 1px solid rgba($error-value-color, 0.5); + background: rgba($error-value-color, 0.25); + color: $error-value-color; + } + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } + } + + p { + margin-bottom: 15px; + } + + .oauth-code { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: $font-monospace, monospace; + background: $ui-base-color; + color: $primary-text-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + } + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } +} + +.form-footer { + margin-top: 30px; + text-align: center; + + a { + color: $darker-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.quick-nav { + list-style: none; + margin-bottom: 25px; + font-size: 14px; + + li { + display: inline-block; + margin-right: 10px; + } + + a { + color: $highlight-text-color; + text-transform: uppercase; + text-decoration: none; + font-weight: 700; + + &:hover, + &:focus, + &:active { + color: lighten($highlight-text-color, 8%); + } + } +} + +.oauth-prompt, +.follow-prompt { + margin-bottom: 30px; + color: $darker-text-color; + + h2 { + font-size: 16px; + margin-bottom: 30px; + text-align: center; + } + + strong { + color: $secondary-text-color; + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } +} + +.qr-wrapper { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.qr-code { + flex: 0 0 auto; + background: $simple-background-color; + padding: 4px; + margin: 0 10px 20px 0; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + display: inline-block; + + svg { + display: block; + margin: 0; + } +} + +.qr-alternative { + margin-bottom: 20px; + color: $secondary-text-color; + flex: 150px; + + samp { + display: block; + font-size: 14px; + } +} + +.table-form { + p { + margin-bottom: 15px; + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + } +} + +.simple_form, +.table-form { + .warning { + box-sizing: border-box; + background: rgba($error-value-color, 0.5); + color: $primary-text-color; + text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3); + box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4); + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; + + a { + color: $primary-text-color; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + strong { + font-weight: 600; + display: block; + margin-bottom: 5px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + + .fa { + font-weight: 400; + } + } + } +} + +.action-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + + .actions, + .pagination { + flex: 1 1 auto; + } + + .actions { + padding: 30px 0; + padding-right: 20px; + flex: 0 0 auto; + } +} + +.post-follow-actions { + text-align: center; + color: $darker-text-color; + + div { + margin-bottom: 4px; + } +} + +.alternative-login { + margin-top: 20px; + margin-bottom: 20px; + + h4 { + font-size: 16px; + color: $primary-text-color; + text-align: center; + margin-bottom: 20px; + border: 0; + padding: 0; + } + + .button { + display: block; + } +} + +.scope-danger { + color: $warning-red; +} + +.form_admin_settings_site_short_description, +.form_admin_settings_site_description, +.form_admin_settings_site_extended_description, +.form_admin_settings_site_terms, +.form_admin_settings_custom_css, +.form_admin_settings_closed_registrations_message { + textarea { + font-family: $font-monospace, monospace; + } +} + +.input-copy { + background: darken($ui-base-color, 10%); + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + display: flex; + align-items: center; + padding-right: 4px; + position: relative; + top: 1px; + transition: border-color 300ms linear; + + &__wrapper { + flex: 1 1 auto; + } + + input[type=text] { + background: transparent; + border: 0; + padding: 10px; + font-size: 14px; + font-family: $font-monospace, monospace; + } + + button { + flex: 0 0 auto; + margin: 4px; + text-transform: none; + font-weight: 400; + font-size: 14px; + padding: 7px 18px; + padding-bottom: 6px; + width: auto; + transition: background 300ms linear; + } + + &.copied { + border-color: $valid-value-color; + transition: none; + + button { + background: $valid-value-color; + transition: none; + } + } +} + +.connection-prompt { + margin-bottom: 25px; + + .fa-link { + background-color: darken($ui-base-color, 4%); + border-radius: 100%; + font-size: 24px; + padding: 10px; + } + + &__column { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + flex-shrink: 1; + max-width: 50%; + + &-sep { + align-self: center; + flex-grow: 0; + overflow: visible; + position: relative; + z-index: 1; + } + + p { + word-break: break-word; + } + } + + .account__avatar { + margin-bottom: 20px; + } + + &__connection { + background-color: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + padding: 25px 10px; + position: relative; + text-align: center; + + &::after { + background-color: darken($ui-base-color, 4%); + content: ''; + display: block; + height: 100%; + left: 50%; + position: absolute; + top: 0; + width: 1px; + } + } + + &__row { + align-items: flex-start; + display: flex; + flex-direction: row; + } +} diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss new file mode 100644 index 000000000..323b2e7fe --- /dev/null +++ b/app/javascript/flavours/glitch/styles/index.scss @@ -0,0 +1,25 @@ +@import 'mixins'; +@import 'variables'; +@import 'styles/fonts/roboto'; +@import 'styles/fonts/roboto-mono'; +@import 'styles/fonts/montserrat'; + +@import 'reset'; +@import 'basics'; +@import 'containers'; +@import 'lists'; +@import 'modal'; +@import 'footer'; +@import 'compact_header'; +@import 'widgets'; +@import 'forms'; +@import 'accounts'; +@import 'stream_entries'; +@import 'components/index'; +@import 'polls'; +@import 'about'; +@import 'tables'; +@import 'admin'; +@import 'accessibility'; +@import 'rtl'; +@import 'dashboard'; diff --git a/app/javascript/flavours/glitch/styles/lists.scss b/app/javascript/flavours/glitch/styles/lists.scss new file mode 100644 index 000000000..6019cd800 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/lists.scss @@ -0,0 +1,19 @@ +.no-list { + list-style: none; + + li { + display: inline-block; + margin: 0 5px; + } +} + +.recovery-codes { + list-style: none; + margin: 0 auto; + + li { + font-size: 125%; + line-height: 1.5; + letter-spacing: 1px; + } +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light.scss b/app/javascript/flavours/glitch/styles/mastodon-light.scss new file mode 100644 index 000000000..8fc132651 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/mastodon-light.scss @@ -0,0 +1,3 @@ +@import 'mastodon-light/variables'; +@import 'index'; +@import 'mastodon-light/diff'; diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss new file mode 100644 index 000000000..224272f24 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -0,0 +1,374 @@ +// Notes! +// Sass color functions, "darken" and "lighten" are automatically replaced. + +.glitch.local-settings { + background: $ui-base-color; + + &__navigation { + background: darken($ui-base-color, 8%); + } + + &__navigation__item { + background: darken($ui-base-color, 8%); + + &:hover { + background: $ui-base-color; + } + } +} + +.notification__dismiss-overlay { + .wrappy { + box-shadow: unset; + } + + .ckbox { + text-shadow: unset; + } +} + +.status.status-direct { + background: darken($ui-base-color, 8%); + + &.collapsed> .status__content:after { + background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1)); + } +} + +.focusable:focus.status.status-direct { + background: darken($ui-base-color, 4%); + + &.collapsed> .status__content:after { + background: linear-gradient(rgba(darken($ui-base-color, 4%), 0), rgba(darken($ui-base-color, 4%), 1)); + } +} + +// Change columns' default background colors +.column { + > .scrollable { + background: darken($ui-base-color, 13%); + } +} + +.status.collapsed .status__content:after { + background: linear-gradient(rgba(darken($ui-base-color, 13%), 0), rgba(darken($ui-base-color, 13%), 1)); +} + +.drawer__inner { + background: $ui-base-color; +} + +.drawer__inner__mastodon { + background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color(darken($ui-base-color, 13%))}"/></svg>') no-repeat bottom / 100% auto !important; + + .mastodon { + filter: contrast(75%) brightness(75%) !important; + } +} + +// Change the default appearance of the content warning button +.status__content { + + .status__content__spoiler-link { + + background: lighten($ui-base-color, 30%); + + &:hover { + background: lighten($ui-base-color, 35%); + text-decoration: none; + } + + } + +} + +// Change the background colors of media and video spoilers +.media-spoiler, +.video-player__spoiler, +.account-gallery__item a { + background: $ui-base-color; +} + +// Change the colors used in the dropdown menu +.dropdown-menu { + background: $ui-base-color; +} + +.dropdown-menu__arrow { + + &.left { + border-left-color: $ui-base-color; + } + + &.top { + border-top-color: $ui-base-color; + } + + &.bottom { + border-bottom-color: $ui-base-color; + } + + &.right { + border-right-color: $ui-base-color; + } + +} + +.dropdown-menu__item { + a { + background: $ui-base-color; + color: $ui-secondary-color; + } +} + +// Change the default color of several parts of the compose form +.composer { + + .composer--spoiler input, .composer--textarea textarea { + color: lighten($ui-base-color, 80%); + + &:disabled { background: lighten($simple-background-color, 10%) } + + &::placeholder { + color: lighten($ui-base-color, 70%); + } + } + + .composer--options { + background: lighten($ui-base-color, 10%); + box-shadow: unset; + + & > hr { + display: none; + } + } + + .composer--options--dropdown--content--item { + color: $ui-primary-color; + + strong { + color: $ui-primary-color; + } + + } + +} + +.composer--upload_form--actions .icon-button { + color: lighten($white, 7%); + + &:active, + &:focus, + &:hover { + color: $white; + } +} + +.composer--upload_form--item > div input { + color: lighten($white, 7%); + + &::placeholder { + color: lighten($white, 10%); + } +} + +.dropdown-menu__separator { + border-bottom-color: lighten($ui-base-color, 12%); +} + +.status__content, +.reply-indicator__content { + a { + color: $highlight-text-color; + } +} + +.emoji-mart-bar { + border-color: darken($ui-base-color, 4%); + + &:first-child { + background: lighten($ui-base-color, 10%); + } +} + +.emoji-mart-search input { + background: rgba($ui-base-color, 0.3); + border-color: $ui-base-color; +} + +.autosuggest-textarea__suggestions { + background: lighten($ui-base-color, 10%) +} + +.autosuggest-textarea__suggestions__item { + &:hover, + &:focus, + &:active, + &.selected { + background: darken($ui-base-color, 4%); + } +} + +.react-toggle-track { + background: $ui-secondary-color; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background: lighten($ui-secondary-color, 10%); +} + +.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background: darken($ui-highlight-color, 10%); +} + +// Change the background colors of modals +.actions-modal, +.boost-modal, +.confirmation-modal, +.mute-modal, +.report-modal, +.embed-modal, +.error-modal, +.onboarding-modal { + background: $ui-base-color; +} + +.boost-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.onboarding-modal__paginator, +.error-modal__footer { + background: darken($ui-base-color, 6%); + + .onboarding-modal__nav, + .error-modal__nav { + &:hover, + &:focus, + &:active { + background-color: darken($ui-base-color, 12%); + } + } +} + +// Change the default color used for the text in an empty column or on the error column +.empty-column-indicator, +.error-column { + color: lighten($ui-base-color, 60%); +} + +// Change the default colors used on some parts of the profile pages +.activity-stream-tabs { + + background: $account-background-color; + + a { + &.active { + color: $ui-primary-color; + } + } + +} + +.activity-stream { + + .entry { + background: $account-background-color; + } + + .status.light { + + .status__content { + color: $primary-text-color; + } + + .display-name { + strong { + color: $primary-text-color; + } + } + + } + +} + +.accounts-grid { + .account-grid-card { + + .controls { + .icon-button { + color: $ui-secondary-color; + } + } + + .name { + a { + color: $primary-text-color; + } + } + + .username { + color: $ui-secondary-color; + } + + .account__header__content { + color: $primary-text-color; + } + + } +} + +.button.logo-button { + color: $white; + + svg path:first-child { + fill: $white; + } +} + +.public-layout { + .header, + .public-account-header, + .public-account-bio { + box-shadow: none; + } + + .header { + background: lighten($ui-base-color, 12%); + } + + .public-account-header { + &__image { + background: lighten($ui-base-color, 12%); + + &::after { + box-shadow: none; + } + } + + &__tabs { + &__name { + h1, + h1 small { + color: $white; + } + } + } + } +} + +.account__section-headline a.active::after { + border-color: transparent transparent $white; +} + +.hero-widget, +.box-widget, +.contact-widget, +.landing-page__information.contact-widget, +.moved-account-widget, +.memoriam-widget, +.activity-stream, +.nothing-here, +.directory__tag > a, +.directory__tag > div { + box-shadow: none; +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss new file mode 100644 index 000000000..1b060b58d --- /dev/null +++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss @@ -0,0 +1,38 @@ +// Dependent colors +$black: #000000; +$white: #ffffff; + +$classic-base-color: #282c37; +$classic-primary-color: #9baec8; +$classic-secondary-color: #d9e1e8; +$classic-highlight-color: #2b90d9; + +$ui-base-color: $classic-secondary-color !default; +$ui-base-lighter-color: darken($ui-base-color, 57%); +$ui-highlight-color: $classic-highlight-color !default; +$ui-primary-color: $classic-primary-color !default; +$ui-secondary-color: $classic-base-color !default; + +$primary-text-color: $black !default; +$darker-text-color: $classic-base-color !default; +$dark-text-color: #444b5d; +$action-button-color: #606984; + +$base-overlay-background: $white !default; + +$inverted-text-color: $black !default; +$lighter-text-color: $classic-base-color !default; +$light-text-color: #444b5d; + +$account-background-color: $white !default; + +//Invert darkened and lightened colors +@function darken($color, $amount) { + @return hsl(hue($color), saturation($color), lightness($color) + $amount); +} + +@function lighten($color, $amount) { + @return hsl(hue($color), saturation($color), lightness($color) - $amount); +} + +$emojis-requiring-outlines: 'alien' 'baseball' 'chains' 'chicken' 'cloud' 'crescent_moon' 'dash' 'dove_of_peace' 'eyes' 'first_quarter_moon' 'first_quarter_moon_with_face' 'fish_cake' 'full_moon' 'full_moon_with_face' 'ghost' 'goat' 'grey_exclamation' 'grey_question' 'ice_skate' 'last_quarter_moon' 'last_quarter_moon_with_face' 'lightning' 'loud_sound' 'moon' 'mute' 'page_with_curl' 'rain_cloud' 'ram' 'rice' 'rice_ball' 'rooster' 'sheep' 'skull' 'skull_and_crossbones' 'snow_cloud' 'sound' 'speaker' 'speech_balloon' 'thought_balloon' 'volleyball' 'waning_crescent_moon' 'waning_gibbous_moon' 'waving_white_flag' 'waxing_crescent_moon' 'white_circle' 'white_large_square' 'white_medium_small_square' 'white_medium_square' 'white_small_square' 'wind_blowing_face'; diff --git a/app/javascript/flavours/glitch/styles/modal.scss b/app/javascript/flavours/glitch/styles/modal.scss new file mode 100644 index 000000000..10de454c6 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/modal.scss @@ -0,0 +1,26 @@ +.modal-layout { + background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}"/></svg>') repeat-x bottom fixed; + display: flex; + flex-direction: column; + height: 100vh; + padding: 0; +} + +.modal-layout__mastodon { + display: flex; + flex: 1; + flex-direction: column; + justify-content: flex-end; + + > * { + flex: 1; + max-height: 235px; + background: url('~images/elephant_ui_plane.svg') no-repeat left bottom / contain; + } +} + +@media screen and (max-width: 600px) { + .account-header { + margin-top: 0; + } +} diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss new file mode 100644 index 000000000..315fd5782 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -0,0 +1,209 @@ +.poll { + margin-top: 16px; + font-size: 14px; + + li { + margin-bottom: 10px; + position: relative; + height: 18px + 12px; + } + + &__chart { + position: absolute; + top: 0; + left: 0; + height: 100%; + display: inline-block; + border-radius: 4px; + background: darken($ui-primary-color, 14%); + + &.leading { + background: $ui-highlight-color; + } + } + + &__text { + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + .autossugest-input { + flex: 1 1 auto; + } + + input[type=text] { + display: block; + box-sizing: border-box; + width: 100%; + font-size: 14px; + color: $inverted-text-color; + display: block; + outline: 0; + font-family: inherit; + background: $simple-background-color; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + + &:focus { + border-color: $highlight-text-color; + } + } + + &.selectable { + cursor: pointer; + } + + &.editable { + display: flex; + align-items: center; + overflow: visible; + } + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checkbox { + border-radius: 4px; + } + + &.active { + border-color: $valid-value-color; + background: $valid-value-color; + } + } + + &__number { + display: inline-block; + width: 36px; + font-weight: 700; + padding: 0 10px; + text-align: right; + } + + &__footer { + padding-top: 6px; + padding-bottom: 5px; + color: $dark-text-color; + } + + &__link { + display: inline; + background: transparent; + padding: 0; + margin: 0; + border: 0; + color: $dark-text-color; + text-decoration: underline; + font-size: inherit; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + .button { + height: 36px; + padding: 0 16px; + margin-right: 10px; + font-size: 14px; + } +} + +.compose-form__poll-wrapper { + border-top: 1px solid darken($simple-background-color, 8%); + + ul { + padding: 10px; + } + + .poll__footer { + border-top: 1px solid darken($simple-background-color, 8%); + padding: 10px; + display: flex; + align-items: center; + + button, + select { + width: 100%; + flex: 1 1 50%; + } + } + + .button.button-secondary { + font-size: 14px; + font-weight: 400; + padding: 6px 10px; + height: auto; + line-height: inherit; + color: $action-button-color; + border-color: $action-button-color; + margin-right: 5px; + } + + li { + display: flex; + align-items: center; + + .poll__text { + flex: 0 0 auto; + width: calc(100% - (23px + 6px)); + margin-right: 6px; + } + } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } + + .icon-button.disabled { + color: darken($simple-background-color, 14%); + } +} + +.muted .poll { + color: $dark-text-color; + + &__chart { + background: rgba(darken($ui-primary-color, 14%), 0.2); + + &.leading { + background: rgba($ui-highlight-color, 0.2); + } + } +} diff --git a/app/javascript/flavours/glitch/styles/reset.scss b/app/javascript/flavours/glitch/styles/reset.scss new file mode 100644 index 000000000..f54ed5bc7 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/reset.scss @@ -0,0 +1,95 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +body { + line-height: 1; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +html { + scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-thumb { + background: lighten($ui-base-color, 4%); + border: 0px none $base-border-color; + border-radius: 50px; +} + +::-webkit-scrollbar-thumb:hover { + background: lighten($ui-base-color, 6%); +} + +::-webkit-scrollbar-thumb:active { + background: lighten($ui-base-color, 4%); +} + +::-webkit-scrollbar-track { + border: 0px none $base-border-color; + border-radius: 0; + background: rgba($base-overlay-background, 0.1); +} + +::-webkit-scrollbar-track:hover { + background: $ui-base-color; +} + +::-webkit-scrollbar-track:active { + background: $ui-base-color; +} + +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss new file mode 100644 index 000000000..9e0a93ec2 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/rtl.scss @@ -0,0 +1,393 @@ +body.rtl { + direction: rtl; + + .column-header > button { + text-align: right; + padding-left: 0; + padding-right: 15px; + } + + .landing-page__logo { + margin-right: 0; + margin-left: 20px; + } + + .landing-page .features-list .features-list__row .visual { + margin-left: 0; + margin-right: 15px; + } + + .column-link__icon, + .column-header__icon { + margin-right: 0; + margin-left: 5px; + } + + .compose-form .compose-form__buttons-wrapper .character-counter__wrapper { + margin-right: 0; + margin-left: 4px; + } + + .navigation-bar__profile { + margin-left: 0; + margin-right: 8px; + } + + .search__input { + padding-right: 10px; + padding-left: 30px; + } + + .search__icon .fa { + right: auto; + left: 10px; + } + + .column-header__buttons { + left: 0; + right: auto; + margin-left: -15px; + margin-right: 0; + } + + .column-inline-form .icon-button { + margin-left: 0; + margin-right: 5px; + } + + .column-header__links .text-btn { + margin-left: 10px; + margin-right: 0; + } + + .account__avatar-wrapper { + float: right; + } + + .column-header__back-button { + padding-left: 5px; + padding-right: 0; + } + + .column-header__setting-arrows { + float: left; + } + + .setting-toggle__label { + margin-left: 0; + margin-right: 8px; + } + + .setting-meta__label { + float: left; + } + + .status__avatar { + left: auto; + right: 10px; + } + + .status, + .activity-stream .status.light { + padding-left: 10px; + padding-right: 68px; + } + + .status__info .status__display-name, + .activity-stream .status.light .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .activity-stream .pre-header { + padding-right: 68px; + padding-left: 0; + } + + .status__prepend { + margin-left: 0; + margin-right: 68px; + } + + .status__prepend-icon-wrapper { + left: auto; + right: -26px; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__avatar-overlay-overlay { + right: auto; + left: 0; + } + + .column-back-button--slim-button { + right: auto; + left: 0; + } + + .status__relative-time, + .activity-stream .status.light .status__header .status__meta { + float: left; + } + + .activity-stream .detailed-status.light .detailed-status__display-name > div { + float: right; + margin-right: 0; + margin-left: 10px; + } + + .activity-stream .detailed-status.light .detailed-status__meta span > span { + margin-left: 0; + margin-right: 6px; + } + + .status__action-bar { + + &__counter { + margin-right: 0; + margin-left: 11px; + + .status__action-bar-button { + margin-right: 0; + margin-left: 4px; + } + } + } + + .status__action-bar-button { + float: right; + margin-right: 0; + margin-left: 18px; + } + + .status__action-bar-dropdown { + float: right; + } + + .privacy-dropdown__dropdown { + margin-left: 0; + margin-right: 40px; + } + + .privacy-dropdown__option__icon { + margin-left: 10px; + margin-right: 0; + } + + .detailed-status__display-avatar { + margin-right: 0; + margin-left: 10px; + float: right; + } + + .detailed-status__favorites, + .detailed-status__reblogs { + margin-left: 0; + margin-right: 6px; + } + + .fa-ul { + margin-left: 0; + margin-left: 2.14285714em; + } + + .fa-li { + left: auto; + right: -2.14285714em; + } + + .admin-wrapper { + direction: rtl; + } + + .admin-wrapper .sidebar ul a i.fa, + a.table-action-link i.fa { + margin-right: 0; + margin-left: 5px; + } + + .simple_form .check_boxes .checkbox label { + padding-left: 0; + padding-right: 25px; + } + + .simple_form .input.with_label.boolean label.checkbox { + padding-left: 25px; + padding-right: 0; + } + + .simple_form .check_boxes .checkbox input[type="checkbox"], + .simple_form .input.boolean input[type="checkbox"] { + left: auto; + right: 0; + } + + .simple_form .input.radio_buttons .radio { + left: auto; + right: 0; + } + + .simple_form .input.radio_buttons .radio > label { + padding-right: 28px; + padding-left: 0; + } + + .simple_form .input-with-append .input input { + padding-left: 142px; + padding-right: 0; + } + + .simple_form .input.boolean label.checkbox { + left: auto; + right: 0; + } + + .simple_form .input.boolean .label_input, + .simple_form .input.boolean .hint { + padding-left: 0; + padding-right: 28px; + } + + .simple_form .label_input__append { + right: auto; + left: 3px; + + &::after { + right: auto; + left: 0; + background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%)); + } + } + + .simple_form select { + background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") no-repeat left 8px center / auto 16px; + } + + .table th, + .table td { + text-align: right; + } + + .filters .filter-subset { + margin-right: 0; + margin-left: 45px; + } + + .landing-page .header-wrapper .mascot { + right: 60px; + left: auto; + } + + .landing-page__call-to-action .row__information-board { + direction: rtl; + } + + .landing-page .header .hero .floats .float-1 { + left: -120px; + right: auto; + } + + .landing-page .header .hero .floats .float-2 { + left: 210px; + right: auto; + } + + .landing-page .header .hero .floats .float-3 { + left: 110px; + right: auto; + } + + .landing-page .header .links .brand img { + left: 0; + } + + .landing-page .fa-external-link { + padding-right: 5px; + padding-left: 0 !important; + } + + .landing-page .features #mastodon-timeline { + margin-right: 0; + margin-left: 30px; + } + + @media screen and (min-width: 631px) { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + + &:first-child { + padding-left: 5px; + padding-right: 10px; + } + } + + .columns-area > div { + .column, + .drawer { + padding-left: 5px; + padding-right: 5px; + } + } + } + + .public-layout { + .header { + .nav-button { + margin-left: 8px; + margin-right: 0; + } + } + + .public-account-header__tabs { + margin-left: 0; + margin-right: 20px; + } + } + + .landing-page__information { + .account__display-name { + margin-right: 0; + margin-left: 5px; + } + + .account__avatar-wrapper { + margin-left: 12px; + margin-right: 0; + } + } + + .card__bar .display-name { + margin-left: 0; + margin-right: 15px; + text-align: right; + } + + .fa-chevron-left::before { + content: "\F054"; + } + + .fa-chevron-right::before { + content: "\F053"; + } + + .column-back-button__icon { + margin-right: 0; + margin-left: 5px; + } + + .column-header__setting-arrows .column-header__setting-btn:last-child { + padding-left: 0; + padding-right: 10px; + } + + .simple_form .input.radio_buttons .radio > label input { + left: auto; + right: 0; + } +} diff --git a/app/javascript/flavours/glitch/styles/stream_entries.scss b/app/javascript/flavours/glitch/styles/stream_entries.scss new file mode 100644 index 000000000..e18696fb7 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/stream_entries.scss @@ -0,0 +1,236 @@ +.activity-stream { + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; + + @media screen and (max-width: $no-gap-breakpoint) { + margin-bottom: 0; + border-radius: 0; + box-shadow: none; + } + + &--headless { + border-radius: 0; + margin: 0; + box-shadow: none; + + .detailed-status, + .status { + border-radius: 0 !important; + } + } + + div[data-component] { + width: 100%; + } + + .entry { + background: $ui-base-color; + + .detailed-status, + .status, + .load-more { + animation: none; + } + + &:last-child { + .detailed-status, + .status, + .load-more { + border-bottom: 0; + border-radius: 0 0 4px 4px; + } + } + + &:first-child { + .detailed-status, + .status, + .load-more { + border-radius: 4px 4px 0 0; + } + + &:last-child { + .detailed-status, + .status, + .load-more { + border-radius: 4px; + } + } + } + + @media screen and (max-width: 740px) { + .detailed-status, + .status, + .load-more { + border-radius: 0 !important; + } + } + } + + &--highlighted .entry { + background: lighten($ui-base-color, 8%); + } +} + +.button.logo-button { + flex: 0 auto; + font-size: 14px; + background: $ui-highlight-color; + color: $primary-text-color; + text-transform: none; + line-height: 36px; + height: auto; + padding: 3px 15px; + border: 0; + + svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-right: 5px; + + path:first-child { + fill: $primary-text-color; + } + + path:last-child { + fill: $ui-highlight-color; + } + } + + &:active, + &:focus, + &:hover { + background: lighten($ui-highlight-color, 10%); + + svg path:last-child { + fill: lighten($ui-highlight-color, 10%); + } + } + + &:disabled, + &.disabled { + svg path:last-child { + fill: $ui-primary-color; + } + + &:active, + &:focus, + &:hover { + background: $ui-primary-color; + + svg path:last-child { + fill: $ui-primary-color; + } + } + } + + &.button--destructive { + &:active, + &:focus, + &:hover { + background: $error-red; + + svg path:last-child { + fill: $error-red; + } + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + svg { + display: none; + } + } +} + +.embed, +.public-layout { + .detailed-status { + padding: 15px; + } + + .status { + padding: 15px 15px 15px (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + left: 15px; + top: 17px; + } + + &__content { + padding-top: 5px; + } + + &__prepend { + padding: 8px 0; + padding-bottom: 2px; + margin: initial; + margin-left: 48px + 15px * 2; + padding-top: 15px; + } + + &__prepend-icon-wrapper { + position: absolute; + margin: initial; + float: initial; + width: auto; + left: -32px; + } + + .media-gallery, + &__action-bar, + .video-player { + margin-top: 10px; + } + } +} + +// Styling from upstream's WebUI, as public pages use the same layout +.embed, +.public-layout { + .status { + .status__info { + font-size: 15px; + display: initial; + } + + .status__relative-time { + color: $dark-text-color; + float: right; + font-size: 14px; + width: auto; + margin: initial; + padding: initial; + } + + .status__info .status__display-name { + display: block; + max-width: 100%; + padding: 6px 0; + padding-right: 25px; + margin: initial; + + .display-name strong { + display: inline; + } + } + + .status__avatar { + height: 48px; + position: absolute; + width: 48px; + margin: initial; + } + } +} + +.rtl { + .embed, .public-layout { + .status .status__relative-time { + float: left; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss new file mode 100644 index 000000000..154844665 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/tables.scss @@ -0,0 +1,239 @@ +.table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + + th, + td { + padding: 8px; + line-height: 18px; + vertical-align: top; + border-top: 1px solid $ui-base-color; + text-align: left; + background: darken($ui-base-color, 4%); + } + + & > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid $ui-base-color; + border-top: 0; + font-weight: 500; + } + + & > tbody > tr > th { + font-weight: 500; + } + + & > tbody > tr:nth-child(odd) > td, + & > tbody > tr:nth-child(odd) > th { + background: $ui-base-color; + } + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + &.inline-table { + & > tbody > tr:nth-child(odd) { + & > td, + & > th { + background: transparent; + } + } + + & > tbody > tr:first-child { + & > td, + & > th { + border-top: 0; + } + } + } + + &.batch-table { + & > thead > tr > th { + background: $ui-base-color; + border-top: 1px solid darken($ui-base-color, 8%); + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:first-child { + border-radius: 4px 0 0; + border-left: 1px solid darken($ui-base-color, 8%); + } + + &:last-child { + border-radius: 0 4px 0 0; + border-right: 1px solid darken($ui-base-color, 8%); + } + } + } + + &--invites tbody td { + vertical-align: middle; + } +} + +.table-wrapper { + overflow: auto; + margin-bottom: 20px; +} + +samp { + font-family: $font-monospace, monospace; +} + +button.table-action-link { + background: transparent; + border: 0; + font: inherit; +} + +button.table-action-link, +a.table-action-link { + text-decoration: none; + display: inline-block; + margin-right: 5px; + padding: 0 10px; + color: $darker-text-color; + font-weight: 500; + + &:hover { + color: $primary-text-color; + } + + i.fa { + font-weight: 400; + margin-right: 5px; + } + + &:first-child { + padding-left: 0; + } +} + +.batch-table { + &__toolbar, + &__row { + display: flex; + + &__select { + box-sizing: border-box; + padding: 8px 16px; + cursor: pointer; + min-height: 100%; + + input { + margin-top: 8px; + } + + &--aligned { + display: flex; + align-items: center; + + input { + margin-top: 0; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } + } + + &__actions, + &__content { + padding: 8px 0; + padding-right: 16px; + flex: 1 1 auto; + } + } + + &__toolbar { + border: 1px solid darken($ui-base-color, 8%); + background: $ui-base-color; + border-radius: 4px 0 0; + height: 47px; + align-items: center; + + &__actions { + text-align: right; + padding-right: 16px - 5px; + } + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } + } + + &__row { + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + background: darken($ui-base-color, 4%); + + @media screen and (max-width: $no-gap-breakpoint) { + &:first-child { + border-top: 1px solid darken($ui-base-color, 8%); + } + } + + &:hover { + background: darken($ui-base-color, 2%); + } + + &:nth-child(even) { + background: $ui-base-color; + + &:hover { + background: lighten($ui-base-color, 2%); + } + } + + &__content { + padding-top: 12px; + padding-bottom: 16px; + + &--unpadded { + padding: 0; + } + } + } + + .status__content { + padding-top: 0; + + strong { + font-weight: 700; + } + } + + .nothing-here { + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + box-shadow: none; + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 1px solid darken($ui-base-color, 8%); + } + } + + @media screen and (max-width: 870px) { + .accounts-table tbody td.optional { + display: none; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss new file mode 100644 index 000000000..1ed1a5778 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -0,0 +1,62 @@ +// Commonly used web colors +$black: #000000; // Black +$white: #ffffff; // White +$success-green: #79bd9a; // Padua +$error-red: #df405a; // Cerise +$warning-red: #ff5050; // Sunset Orange +$gold-star: #ca8f04; // Dark Goldenrod + +$red-bookmark: $warning-red; + +// Values from the classic Mastodon UI +$classic-base-color: #282c37; // Midnight Express +$classic-primary-color: #9baec8; // Echo Blue +$classic-secondary-color: #d9e1e8; // Pattens Blue +$classic-highlight-color: #2b90d9; // Summer Sky + +// Variables for defaults in UI +$base-shadow-color: $black !default; +$base-overlay-background: $black !default; +$base-border-color: $white !default; +$simple-background-color: $white !default; +$valid-value-color: $success-green !default; +$error-value-color: $error-red !default; + +// Tell UI to use selected colors +$ui-base-color: $classic-base-color !default; // Darkest +$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest +$ui-primary-color: $classic-primary-color !default; // Lighter +$ui-secondary-color: $classic-secondary-color !default; // Lightest +$ui-highlight-color: $classic-highlight-color !default; + +// Variables for texts +$primary-text-color: $white !default; +$darker-text-color: $ui-primary-color !default; +$dark-text-color: $ui-base-lighter-color !default; +$secondary-text-color: $ui-secondary-color !default; +$highlight-text-color: $ui-highlight-color !default; +$action-button-color: $ui-base-lighter-color !default; +// For texts on inverted backgrounds +$inverted-text-color: $ui-base-color !default; +$lighter-text-color: $ui-base-lighter-color !default; +$light-text-color: $ui-primary-color !default; + +// Language codes that uses CJK fonts +$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; + +// Variables for components +$media-modal-media-max-width: 100%; +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; + +$no-gap-breakpoint: 415px; + +$font-sans-serif: 'mastodon-font-sans-serif' !default; +$font-display: 'mastodon-font-display' !default; +$font-monospace: 'mastodon-font-monospace' !default; + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; + +// More variables +$dismiss-overlay-width: 4rem; diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss new file mode 100644 index 000000000..acaf5b024 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -0,0 +1,534 @@ +.hero-widget { + margin-bottom: 10px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &__img { + width: 100%; + position: relative; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: $base-shadow-color; + + img { + object-fit: cover; + display: block; + width: 100%; + height: 100%; + margin: 0; + border-radius: 4px 4px 0 0; + } + } + + &__text { + background: $ui-base-color; + padding: 20px; + border-radius: 0 0 4px 4px; + font-size: 15px; + color: $darker-text-color; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 700; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($darker-text-color, 10%); + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } +} + +.endorsements-widget { + margin-bottom: 10px; + padding-bottom: 10px; + + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; + } + + .account { + padding: 10px 0; + + &:last-child { + border-bottom: 0; + } + + .account__display-name { + display: flex; + align-items: center; + } + + .account__avatar { + width: 44px; + height: 44px; + background-size: 44px 44px; + } + } +} + +.box-widget { + padding: 20px; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); +} + +.contact-widget, +.landing-page__information.contact-widget { + box-sizing: border-box; + padding: 20px; + min-height: 100%; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); +} + +.contact-widget { + font-size: 15px; + color: $darker-text-color; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + + strong { + font-weight: 500; + } + + p { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + + &__mail { + margin-top: 10px; + + a { + color: $primary-text-color; + text-decoration: none; + } + } +} + +.moved-account-widget { + padding: 15px; + padding-bottom: 20px; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + color: $secondary-text-color; + font-weight: 400; + margin-bottom: 10px; + + strong, + a { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + a { + color: inherit; + text-decoration: underline; + + &.mention { + text-decoration: none; + + span { + text-decoration: none; + } + + &:focus, + &:hover, + &:active { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + } + + &__message { + margin-bottom: 15px; + + .fa { + margin-right: 5px; + color: $darker-text-color; + } + } + + &__card { + .detailed-status__display-avatar { + position: relative; + cursor: pointer; + } + + .detailed-status__display-name { + margin-bottom: 0; + text-decoration: none; + + span { + font-weight: 400; + } + } + } +} + +.memoriam-widget { + padding: 20px; + border-radius: 4px; + background: $base-shadow-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + font-size: 14px; + color: $darker-text-color; + margin-bottom: 10px; +} + +.page-header { + background: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + padding: 60px 15px; + text-align: center; + margin: 10px 0; + + h1 { + color: $primary-text-color; + font-size: 36px; + line-height: 1.1; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 15px; + color: $darker-text-color; + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin-top: 0; + background: lighten($ui-base-color, 4%); + + h1 { + font-size: 24px; + } + } +} + +.directory { + background: $ui-base-color; + border-radius: 4px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &__tag { + box-sizing: border-box; + margin-bottom: 10px; + + & > a, + & > div { + display: flex; + align-items: center; + justify-content: space-between; + background: $ui-base-color; + border-radius: 4px; + padding: 15px; + text-decoration: none; + color: inherit; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + } + + & > a { + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 8%); + } + } + + &.active > a { + background: $ui-highlight-color; + cursor: default; + } + + &.disabled > div { + opacity: 0.5; + cursor: default; + } + + h4 { + flex: 1 1 auto; + font-size: 18px; + font-weight: 700; + color: $primary-text-color; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .fa { + color: $darker-text-color; + } + + small { + display: block; + font-weight: 400; + font-size: 15px; + margin-top: 8px; + color: $darker-text-color; + } + } + + &.active h4 { + &, + .fa, + small { + color: $primary-text-color; + } + } + + .avatar-stack { + flex: 0 0 auto; + width: (36px + 4px) * 3; + } + + &.active .avatar-stack .account__avatar { + border-color: $ui-highlight-color; + } + } +} + +.avatar-stack { + display: flex; + justify-content: flex-end; + + .account__avatar { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 50%; + position: relative; + margin-left: -10px; + background: darken($ui-base-color, 8%); + border: 2px solid $ui-base-color; + + &:nth-child(1) { + z-index: 1; + } + + &:nth-child(2) { + z-index: 2; + } + + &:nth-child(3) { + z-index: 3; + } + } +} + +.accounts-table { + width: 100%; + + .account { + padding: 0; + border: 0; + } + + strong { + font-weight: 700; + } + + thead th { + text-align: center; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 700; + padding: 10px; + + &:first-child { + text-align: left; + } + } + + tbody td { + padding: 15px 0; + vertical-align: middle; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + tbody tr:last-child td { + border-bottom: 0; + } + + &__count { + width: 120px; + text-align: center; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + + small { + display: block; + color: $darker-text-color; + font-weight: 400; + font-size: 14px; + } + } + + &__comment { + width: 50%; + vertical-align: initial !important; + } + + @media screen and (max-width: $no-gap-breakpoint) { + tbody td.optional { + display: none; + } + } +} + +.moved-account-widget, +.memoriam-widget, +.box-widget, +.contact-widget, +.landing-page__information.contact-widget, +.directory, +.page-header { + @media screen and (max-width: $no-gap-breakpoint) { + margin-bottom: 0; + box-shadow: none; + border-radius: 0; + } +} + +$maximum-width: 1235px; +$fluid-breakpoint: $maximum-width + 20px; + +.statuses-grid { + min-height: 600px; + + @media screen and (max-width: 640px) { + width: 100% !important; // Masonry layout is unnecessary at this width + } + + &__item { + width: (960px - 20px) / 3; + + @media screen and (max-width: $fluid-breakpoint) { + width: (940px - 20px) / 3; + } + + @media screen and (max-width: 640px) { + width: 100%; + } + + @media screen and (max-width: $no-gap-breakpoint) { + width: 100vw; + } + } + + .detailed-status { + border-radius: 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 1px solid lighten($ui-base-color, 16%); + } + + &.compact { + .detailed-status__meta { + margin-top: 15px; + } + + .status__content { + font-size: 15px; + line-height: 20px; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + .status__content__spoiler-link { + line-height: 20px; + margin: 0; + } + } + + .media-gallery, + .status-card, + .video-player { + margin-top: 15px; + } + } + } +} + +.notice-widget { + margin-bottom: 10px; + color: $darker-text-color; + + p { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + font-size: 14px; + line-height: 20px; + text-decoration: none; + font-weight: 500; + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml new file mode 100644 index 000000000..06e26ade2 --- /dev/null +++ b/app/javascript/flavours/glitch/theme.yml @@ -0,0 +1,44 @@ +# (REQUIRED) The location of the pack files. +pack: + about: packs/about.js + admin: packs/public.js + auth: + common: + filename: packs/common.js + stylesheet: true + embed: packs/public.js + error: packs/error.js + home: + filename: packs/home.js + preload: + - flavours/glitch/async/compose + - flavours/glitch/async/getting_started + - flavours/glitch/async/home_timeline + - flavours/glitch/async/notifications + mailer: + modal: + public: packs/public.js + settings: + share: packs/share.js + +# (OPTIONAL) The directory which contains localization files for +# the flavour, relative to this directory. The contents of this +# directory must be `.js` or `.json` files whose names correspond to +# language tags and whose default exports are a messages object. +locales: locales + +# (OPTIONAL) A file to use as the preview screenshot for the flavour, +# or an array thereof. These are the full path from `app/javascript/`. +screenshot: flavours/glitch/images/glitch-preview.jpg + +# (OPTIONAL) The directory which contains the pack files. +# Defaults to the theme directory (`app/javascript/themes/[theme]`), +# which should be sufficient for like 99% of use-cases lol. + +# pack_directory: app/javascript/packs + +# (OPTIONAL) By default the theme will fallback to the default theme +# if a particular pack is not provided. You can specify different +# fallbacks here, or disable fallback behaviours altogether by +# specifying a `null` value. +fallback: diff --git a/app/javascript/flavours/glitch/util/api.js b/app/javascript/flavours/glitch/util/api.js new file mode 100644 index 000000000..c59a24518 --- /dev/null +++ b/app/javascript/flavours/glitch/util/api.js @@ -0,0 +1,38 @@ +import axios from 'axios'; +import ready from './ready'; +import LinkHeader from 'http-link-header'; + +export const getLinks = response => { + const value = response.headers.link; + + if (!value) { + return { refs: [] }; + } + + return LinkHeader.parse(value); +}; + +let csrfHeader = {}; + +function setCSRFHeader() { + const csrfToken = document.querySelector('meta[name=csrf-token]'); + if (csrfToken) { + csrfHeader['X-CSRF-Token'] = csrfToken.content; + } +} + +ready(setCSRFHeader); + +export default getState => axios.create({ + headers: Object.assign(csrfHeader, getState ? { + 'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`, + } : {}), + + transformResponse: [function (data) { + try { + return JSON.parse(data); + } catch(Exception) { + return data; + } + }], +}); diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js new file mode 100644 index 000000000..094952204 --- /dev/null +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -0,0 +1,151 @@ +export function EmojiPicker () { + return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/util/emoji/emoji_picker'); +} + +export function Compose () { + return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose'); +} + +export function Notifications () { + return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'flavours/glitch/features/notifications'); +} + +export function HomeTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/home_timeline" */'flavours/glitch/features/home_timeline'); +} + +export function PublicTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/public_timeline" */'flavours/glitch/features/public_timeline'); +} + +export function CommunityTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'flavours/glitch/features/community_timeline'); +} + +export function HashtagTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/hashtag_timeline" */'flavours/glitch/features/hashtag_timeline'); +} + +export function ListTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/list_timeline" */'flavours/glitch/features/list_timeline'); +} + +export function Lists () { + return import(/* webpackChunkName: "flavours/glitch/async/lists" */'flavours/glitch/features/lists'); +} + +export function ListEditor () { + return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor'); +} + +export function PinnedAccountsEditor () { + return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'flavours/glitch/features/pinned_accounts_editor'); +} + +export function DirectTimeline() { + return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'flavours/glitch/features/direct_timeline'); +} + +export function Status () { + return import(/* webpackChunkName: "flavours/glitch/async/status" */'flavours/glitch/features/status'); +} + +export function GettingStarted () { + return import(/* webpackChunkName: "flavours/glitch/async/getting_started" */'flavours/glitch/features/getting_started'); +} + +export function KeyboardShortcuts () { + return import(/* webpackChunkName: "flavours/glitch/async/keyboard_shortcuts" */'flavours/glitch/features/keyboard_shortcuts'); +} + +export function PinnedStatuses () { + return import(/* webpackChunkName: "flavours/glitch/async/pinned_statuses" */'flavours/glitch/features/pinned_statuses'); +} + +export function AccountTimeline () { + return import(/* webpackChunkName: "flavours/glitch/async/account_timeline" */'flavours/glitch/features/account_timeline'); +} + +export function AccountGallery () { + return import(/* webpackChunkName: "flavours/glitch/async/account_gallery" */'flavours/glitch/features/account_gallery'); +} + +export function Followers () { + return import(/* webpackChunkName: "flavours/glitch/async/followers" */'flavours/glitch/features/followers'); +} + +export function Following () { + return import(/* webpackChunkName: "flavours/glitch/async/following" */'flavours/glitch/features/following'); +} + +export function Reblogs () { + return import(/* webpackChunkName: "flavours/glitch/async/reblogs" */'flavours/glitch/features/reblogs'); +} + +export function Favourites () { + return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'flavours/glitch/features/favourites'); +} + +export function FollowRequests () { + return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'flavours/glitch/features/follow_requests'); +} + +export function GenericNotFound () { + return import(/* webpackChunkName: "flavours/glitch/async/generic_not_found" */'flavours/glitch/features/generic_not_found'); +} + +export function FavouritedStatuses () { + return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses'); +} + +export function BookmarkedStatuses () { + return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses'); +} + +export function Blocks () { + return import(/* webpackChunkName: "flavours/glitch/async/blocks" */'flavours/glitch/features/blocks'); +} + +export function DomainBlocks () { + return import(/* webpackChunkName: "flavours/glitch/async/domain_blocks" */'flavours/glitch/features/domain_blocks'); +} + +export function Mutes () { + return import(/* webpackChunkName: "flavours/glitch/async/mutes" */'flavours/glitch/features/mutes'); +} + +export function OnboardingModal () { + return import(/* webpackChunkName: "flavours/glitch/async/onboarding_modal" */'flavours/glitch/features/ui/components/onboarding_modal'); +} + +export function MuteModal () { + return import(/* webpackChunkName: "flavours/glitch/async/mute_modal" */'flavours/glitch/features/ui/components/mute_modal'); +} + +export function ReportModal () { + return import(/* webpackChunkName: "flavours/glitch/async/report_modal" */'flavours/glitch/features/ui/components/report_modal'); +} + +export function SettingsModal () { + return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'flavours/glitch/features/local_settings'); +} + +export function MediaGallery () { + return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'flavours/glitch/components/media_gallery'); +} + +export function Video () { + return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video'); +} + +export function EmbedModal () { + return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal'); +} + +export function GettingStartedMisc () { + return import(/* webpackChunkName: "flavours/glitch/async/getting_started_misc" */'flavours/glitch/features/getting_started_misc'); +} + +export function ListAdder () { + return import(/* webpackChunkName: "features/glitch/async/list_adder" */'flavours/glitch/features/list_adder'); +} diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js new file mode 100644 index 000000000..4fc03f919 --- /dev/null +++ b/app/javascript/flavours/glitch/util/backend_links.js @@ -0,0 +1,6 @@ +export const preferencesLink = '/settings/preferences'; +export const profileLink = '/settings/profile'; +export const signOutLink = '/auth/sign_out'; +export const termsLink = '/terms'; +export const accountAdminLink = (id) => `/admin/accounts/${id}`; +export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; diff --git a/app/javascript/flavours/glitch/util/base64.js b/app/javascript/flavours/glitch/util/base64.js new file mode 100644 index 000000000..8226e2c54 --- /dev/null +++ b/app/javascript/flavours/glitch/util/base64.js @@ -0,0 +1,10 @@ +export const decode = base64 => { + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +}; diff --git a/app/javascript/flavours/glitch/util/base_polyfills.js b/app/javascript/flavours/glitch/util/base_polyfills.js new file mode 100644 index 000000000..ad023eb73 --- /dev/null +++ b/app/javascript/flavours/glitch/util/base_polyfills.js @@ -0,0 +1,44 @@ +import 'intl'; +import 'intl/locale-data/jsonp/en'; +import 'es6-symbol/implement'; +import includes from 'array-includes'; +import assign from 'object-assign'; +import values from 'object.values'; +import isNaN from 'is-nan'; +import { decode as decodeBase64 } from './base64'; + +if (!Array.prototype.includes) { + includes.shim(); +} + +if (!Object.assign) { + Object.assign = assign; +} + +if (!Object.values) { + values.shim(); +} + +if (!Number.isNaN) { + Number.isNaN = isNaN; +} + +if (!HTMLCanvasElement.prototype.toBlob) { + const BASE64_MARKER = ';base64,'; + + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + value(callback, type = 'image/png', quality) { + const dataURL = this.toDataURL(type, quality); + let data; + + if (dataURL.indexOf(BASE64_MARKER) >= 0) { + const [, base64] = dataURL.split(BASE64_MARKER); + data = decodeBase64(base64); + } else { + [, data] = dataURL.split(','); + } + + callback(new Blob([data], { type })); + }, + }); +} diff --git a/app/javascript/flavours/glitch/util/compare_id.js b/app/javascript/flavours/glitch/util/compare_id.js new file mode 100644 index 000000000..aaff66481 --- /dev/null +++ b/app/javascript/flavours/glitch/util/compare_id.js @@ -0,0 +1,10 @@ +export default function compareId(id1, id2) { + if (id1 === id2) { + return 0; + } + if (id1.length === id2.length) { + return id1 > id2 ? 1 : -1; + } else { + return id1.length > id2.length ? 1 : -1; + } +} diff --git a/app/javascript/flavours/glitch/util/content_warning.js b/app/javascript/flavours/glitch/util/content_warning.js new file mode 100644 index 000000000..5e874a49c --- /dev/null +++ b/app/javascript/flavours/glitch/util/content_warning.js @@ -0,0 +1,24 @@ +export function autoUnfoldCW (settings, status) { + if (!settings.getIn(['content_warnings', 'auto_unfold'])) { + return false; + } + + const rawRegex = settings.getIn(['content_warnings', 'filter']); + + if (!rawRegex) { + return true; + } + + let regex = null; + + try { + regex = rawRegex && new RegExp(rawRegex.trim(), 'i'); + } catch (e) { + // Bad regex, don't affect filters + } + + if (!(status && regex)) { + return undefined; + } + return !regex.test(status.get('spoiler_text')); +} diff --git a/app/javascript/flavours/glitch/util/counter.js b/app/javascript/flavours/glitch/util/counter.js new file mode 100644 index 000000000..700ba2163 --- /dev/null +++ b/app/javascript/flavours/glitch/util/counter.js @@ -0,0 +1,9 @@ +import { urlRegex } from './url_regex'; + +const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; + +export function countableText(inputText) { + return inputText + .replace(urlRegex, urlPlaceholder) + .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3'); +}; diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js new file mode 100644 index 000000000..3e1f4a26d --- /dev/null +++ b/app/javascript/flavours/glitch/util/dom_helpers.js @@ -0,0 +1,14 @@ +// Package imports. +import detectPassiveEvents from 'detect-passive-events'; + +// This will either be a passive lister options object (if passive +// events are supported), or `false`. +export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false; + +// Focuses the root element. +export function focusRoot () { + let e; + if (document && (e = document.querySelector('.ui')) && (e = e.parentElement)) { + e.focus(); + } +} diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_compressed.js b/app/javascript/flavours/glitch/util/emoji/emoji_compressed.js new file mode 100644 index 000000000..48d90201a --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_compressed.js @@ -0,0 +1,100 @@ +// @preval +// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt +// This file contains the compressed version of the emoji data from +// both emoji_map.json and from emoji-mart's emojiIndex and data objects. +// It's designed to be emitted in an array format to take up less space +// over the wire. + +const { unicodeToFilename } = require('./unicode_to_filename'); +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +const emojiMap = require('./emoji_map.json'); +const { emojiIndex } = require('emoji-mart'); +const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data'); +let data = require('emoji-mart/data/all.json'); + +if(data.compressed) { + data = emojiMartUncompress(data); +} +const emojiMartData = data; + + +const excluded = ['®', '©', '™']; +const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; +const shortcodeMap = {}; + +const shortCodesToEmojiData = {}; +const emojisWithoutShortCodes = []; + +Object.keys(emojiIndex.emojis).forEach(key => { + shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; +}); + +const stripModifiers = unicode => { + skins.forEach(tone => { + unicode = unicode.replace(tone, ''); + }); + + return unicode; +}; + +Object.keys(emojiMap).forEach(key => { + if (excluded.includes(key)) { + delete emojiMap[key]; + return; + } + + const normalizedKey = stripModifiers(key); + let shortcode = shortcodeMap[normalizedKey]; + + if (!shortcode) { + shortcode = shortcodeMap[normalizedKey + '\uFE0F']; + } + + const filename = emojiMap[key]; + + const filenameData = [key]; + + if (unicodeToFilename(key) !== filename) { + // filename can't be derived using unicodeToFilename + filenameData.push(filename); + } + + if (typeof shortcode === 'undefined') { + emojisWithoutShortCodes.push(filenameData); + } else { + if (!Array.isArray(shortCodesToEmojiData[shortcode])) { + shortCodesToEmojiData[shortcode] = [[]]; + } + shortCodesToEmojiData[shortcode][0].push(filenameData); + } +}); + +Object.keys(emojiIndex.emojis).forEach(key => { + const { native } = emojiIndex.emojis[key]; + let { short_names, search, unified } = emojiMartData.emojis[key]; + if (short_names[0] !== key) { + throw new Error('The compresser expects the first short_code to be the ' + + 'key. It may need to be rewritten if the emoji change such that this ' + + 'is no longer the case.'); + } + + short_names = short_names.slice(1); // first short name can be inferred from the key + + const searchData = [native, short_names, search]; + if (unicodeToUnifiedName(native) !== unified) { + // unified name can't be derived from unicodeToUnifiedName + searchData.push(unified); + } + + shortCodesToEmojiData[key].push(searchData); +}); + +// JSON.parse/stringify is to emulate what @preval is doing and avoid any +// inconsistent behavior in dev mode +module.exports = JSON.parse(JSON.stringify([ + shortCodesToEmojiData, + emojiMartData.skins, + emojiMartData.categories, + emojiMartData.aliases, + emojisWithoutShortCodes, +])); diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_map.json b/app/javascript/flavours/glitch/util/emoji/emoji_map.json new file mode 100644 index 000000000..d46dc82ad --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_map.json @@ -0,0 +1 @@ +{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","🥰":"1f970","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","🥵":"1f975","🥶":"1f976","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🥳":"1f973","🥴":"1f974","🥺":"1f97a","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","🤡":"1f921","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","🏻":"1f3fb","🏼":"1f3fc","🏽":"1f3fd","🏾":"1f3fe","🏿":"1f3ff","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🦸":"1f9b8","🦹":"1f9b9","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","🦵":"1f9b5","🦶":"1f9b6","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","🦴":"1f9b4","🦷":"1f9b7","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","🥽":"1f97d","🥼":"1f97c","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","🥾":"1f97e","🥿":"1f97f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🦝":"1f99d","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦙":"1f999","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🦛":"1f99b","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🦘":"1f998","🦡":"1f9a1","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦢":"1f9a2","🦉":"1f989","🦚":"1f99a","🦜":"1f99c","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦞":"1f99e","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","🦟":"1f99f","🦠":"1f9a0","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🥭":"1f96d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥬":"1f96c","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥯":"1f96f","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🧂":"1f9c2","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🥮":"1f96e","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🧁":"1f9c1","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🧭":"1f9ed","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🧱":"1f9f1","🏘":"1f3d8","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🏙":"1f3d9","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛹":"1f6f9","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","🛢":"1f6e2","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🛑":"1f6d1","🚧":"1f6a7","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🧳":"1f9f3","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","🧨":"1f9e8","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🧧":"1f9e7","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🥎":"1f94e","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🥏":"1f94f","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🥍":"1f94d","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎯":"1f3af","🎱":"1f3b1","🔮":"1f52e","🧿":"1f9ff","🎮":"1f3ae","🕹":"1f579","🎰":"1f3b0","🎲":"1f3b2","🧩":"1f9e9","🧸":"1f9f8","♠":"2660","♥":"2665","♦":"2666","♣":"2663","♟":"265f","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🧵":"1f9f5","🧶":"1f9f6","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🧮":"1f9ee","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","🧾":"1f9fe","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚖":"2696","🔗":"1f517","⛓":"26d3","🧰":"1f9f0","🧲":"1f9f2","⚗":"2697","🧪":"1f9ea","🧫":"1f9eb","🧬":"1f9ec","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","💉":"1f489","💊":"1f48a","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","🧴":"1f9f4","🧷":"1f9f7","🧹":"1f9f9","🧺":"1f9fa","🧻":"1f9fb","🧼":"1f9fc","🧽":"1f9fd","🧯":"1f9ef","🛒":"1f6d2","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♾":"267e","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🦸🏻":"1f9b8-1f3fb","🦸🏼":"1f9b8-1f3fc","🦸🏽":"1f9b8-1f3fd","🦸🏾":"1f9b8-1f3fe","🦸🏿":"1f9b8-1f3ff","🦹🏻":"1f9b9-1f3fb","🦹🏼":"1f9b9-1f3fc","🦹🏽":"1f9b9-1f3fd","🦹🏾":"1f9b9-1f3fe","🦹🏿":"1f9b9-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","🦵🏻":"1f9b5-1f3fb","🦵🏼":"1f9b5-1f3fc","🦵🏽":"1f9b5-1f3fd","🦵🏾":"1f9b5-1f3fe","🦵🏿":"1f9b5-1f3ff","🦶🏻":"1f9b6-1f3fb","🦶🏼":"1f9b6-1f3fc","🦶🏽":"1f9b6-1f3fd","🦶🏾":"1f9b6-1f3fe","🦶🏿":"1f9b6-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏚️":"1f3da","⛩️":"26e9","🏙️":"1f3d9","♨️":"2668","🛣️":"1f6e3","🛤️":"1f6e4","🛢️":"1f6e2","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","♟️":"265f","🖼️":"1f5bc","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚖️":"2696","⛓️":"26d3","⚗️":"2697","🛏️":"1f6cf","🛋️":"1f6cb","⚰️":"26b0","⚱️":"26b1","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♾️":"267e","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨⚕":"1f468-200d-2695-fe0f","👩⚕":"1f469-200d-2695-fe0f","👨🎓":"1f468-200d-1f393","👩🎓":"1f469-200d-1f393","👨🏫":"1f468-200d-1f3eb","👩🏫":"1f469-200d-1f3eb","👨⚖":"1f468-200d-2696-fe0f","👩⚖":"1f469-200d-2696-fe0f","👨🌾":"1f468-200d-1f33e","👩🌾":"1f469-200d-1f33e","👨🍳":"1f468-200d-1f373","👩🍳":"1f469-200d-1f373","👨🔧":"1f468-200d-1f527","👩🔧":"1f469-200d-1f527","👨🏭":"1f468-200d-1f3ed","👩🏭":"1f469-200d-1f3ed","👨💼":"1f468-200d-1f4bc","👩💼":"1f469-200d-1f4bc","👨🔬":"1f468-200d-1f52c","👩🔬":"1f469-200d-1f52c","👨💻":"1f468-200d-1f4bb","👩💻":"1f469-200d-1f4bb","👨🎤":"1f468-200d-1f3a4","👩🎤":"1f469-200d-1f3a4","👨🎨":"1f468-200d-1f3a8","👩🎨":"1f469-200d-1f3a8","👨✈":"1f468-200d-2708-fe0f","👩✈":"1f469-200d-2708-fe0f","👨🚀":"1f468-200d-1f680","👩🚀":"1f469-200d-1f680","👨🚒":"1f468-200d-1f692","👩🚒":"1f469-200d-1f692","👮♂":"1f46e-200d-2642-fe0f","👮♀":"1f46e-200d-2640-fe0f","🕵♂":"1f575-fe0f-200d-2642-fe0f","🕵♀":"1f575-fe0f-200d-2640-fe0f","💂♂":"1f482-200d-2642-fe0f","💂♀":"1f482-200d-2640-fe0f","👷♂":"1f477-200d-2642-fe0f","👷♀":"1f477-200d-2640-fe0f","👳♂":"1f473-200d-2642-fe0f","👳♀":"1f473-200d-2640-fe0f","👱♂":"1f471-200d-2642-fe0f","👱♀":"1f471-200d-2640-fe0f","👨🦰":"1f468-200d-1f9b0","👩🦰":"1f469-200d-1f9b0","👨🦱":"1f468-200d-1f9b1","👩🦱":"1f469-200d-1f9b1","👨🦲":"1f468-200d-1f9b2","👩🦲":"1f469-200d-1f9b2","👨🦳":"1f468-200d-1f9b3","👩🦳":"1f469-200d-1f9b3","🦸♀":"1f9b8-200d-2640-fe0f","🦸♂":"1f9b8-200d-2642-fe0f","🦹♀":"1f9b9-200d-2640-fe0f","🦹♂":"1f9b9-200d-2642-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧟♀":"1f9df-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🙍♂":"1f64d-200d-2642-fe0f","🙍♀":"1f64d-200d-2640-fe0f","🙎♂":"1f64e-200d-2642-fe0f","🙎♀":"1f64e-200d-2640-fe0f","🙅♂":"1f645-200d-2642-fe0f","🙅♀":"1f645-200d-2640-fe0f","🙆♂":"1f646-200d-2642-fe0f","🙆♀":"1f646-200d-2640-fe0f","💁♂":"1f481-200d-2642-fe0f","💁♀":"1f481-200d-2640-fe0f","🙋♂":"1f64b-200d-2642-fe0f","🙋♀":"1f64b-200d-2640-fe0f","🙇♂":"1f647-200d-2642-fe0f","🙇♀":"1f647-200d-2640-fe0f","🤦♂":"1f926-200d-2642-fe0f","🤦♀":"1f926-200d-2640-fe0f","🤷♂":"1f937-200d-2642-fe0f","🤷♀":"1f937-200d-2640-fe0f","💆♂":"1f486-200d-2642-fe0f","💆♀":"1f486-200d-2640-fe0f","💇♂":"1f487-200d-2642-fe0f","💇♀":"1f487-200d-2640-fe0f","🚶♂":"1f6b6-200d-2642-fe0f","🚶♀":"1f6b6-200d-2640-fe0f","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","🧘♂":"1f9d8-200d-2642-fe0f","🏌♂":"1f3cc-fe0f-200d-2642-fe0f","🏌♀":"1f3cc-fe0f-200d-2640-fe0f","🏄♂":"1f3c4-200d-2642-fe0f","🏄♀":"1f3c4-200d-2640-fe0f","🚣♂":"1f6a3-200d-2642-fe0f","🚣♀":"1f6a3-200d-2640-fe0f","🏊♂":"1f3ca-200d-2642-fe0f","🏊♀":"1f3ca-200d-2640-fe0f","⛹♂":"26f9-fe0f-200d-2642-fe0f","⛹♀":"26f9-fe0f-200d-2640-fe0f","🏋♂":"1f3cb-fe0f-200d-2642-fe0f","🏋♀":"1f3cb-fe0f-200d-2640-fe0f","🚴♂":"1f6b4-200d-2642-fe0f","🚴♀":"1f6b4-200d-2640-fe0f","🚵♂":"1f6b5-200d-2642-fe0f","🚵♀":"1f6b5-200d-2640-fe0f","🤸♂":"1f938-200d-2642-fe0f","🤸♀":"1f938-200d-2640-fe0f","🤼♂":"1f93c-200d-2642-fe0f","🤼♀":"1f93c-200d-2640-fe0f","🤽♂":"1f93d-200d-2642-fe0f","🤽♀":"1f93d-200d-2640-fe0f","🤾♂":"1f93e-200d-2642-fe0f","🤾♀":"1f93e-200d-2640-fe0f","🤹♂":"1f939-200d-2642-fe0f","🤹♀":"1f939-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","👁🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳🌈":"1f3f3-fe0f-200d-1f308","🏴☠":"1f3f4-200d-2620-fe0f","👨⚕️":"1f468-200d-2695-fe0f","👨🏻⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕":"1f468-1f3ff-200d-2695-fe0f","👩⚕️":"1f469-200d-2695-fe0f","👩🏻⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻🎓":"1f468-1f3fb-200d-1f393","👨🏼🎓":"1f468-1f3fc-200d-1f393","👨🏽🎓":"1f468-1f3fd-200d-1f393","👨🏾🎓":"1f468-1f3fe-200d-1f393","👨🏿🎓":"1f468-1f3ff-200d-1f393","👩🏻🎓":"1f469-1f3fb-200d-1f393","👩🏼🎓":"1f469-1f3fc-200d-1f393","👩🏽🎓":"1f469-1f3fd-200d-1f393","👩🏾🎓":"1f469-1f3fe-200d-1f393","👩🏿🎓":"1f469-1f3ff-200d-1f393","👨🏻🏫":"1f468-1f3fb-200d-1f3eb","👨🏼🏫":"1f468-1f3fc-200d-1f3eb","👨🏽🏫":"1f468-1f3fd-200d-1f3eb","👨🏾🏫":"1f468-1f3fe-200d-1f3eb","👨🏿🏫":"1f468-1f3ff-200d-1f3eb","👩🏻🏫":"1f469-1f3fb-200d-1f3eb","👩🏼🏫":"1f469-1f3fc-200d-1f3eb","👩🏽🏫":"1f469-1f3fd-200d-1f3eb","👩🏾🏫":"1f469-1f3fe-200d-1f3eb","👩🏿🏫":"1f469-1f3ff-200d-1f3eb","👨⚖️":"1f468-200d-2696-fe0f","👨🏻⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖":"1f468-1f3ff-200d-2696-fe0f","👩⚖️":"1f469-200d-2696-fe0f","👩🏻⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻🌾":"1f468-1f3fb-200d-1f33e","👨🏼🌾":"1f468-1f3fc-200d-1f33e","👨🏽🌾":"1f468-1f3fd-200d-1f33e","👨🏾🌾":"1f468-1f3fe-200d-1f33e","👨🏿🌾":"1f468-1f3ff-200d-1f33e","👩🏻🌾":"1f469-1f3fb-200d-1f33e","👩🏼🌾":"1f469-1f3fc-200d-1f33e","👩🏽🌾":"1f469-1f3fd-200d-1f33e","👩🏾🌾":"1f469-1f3fe-200d-1f33e","👩🏿🌾":"1f469-1f3ff-200d-1f33e","👨🏻🍳":"1f468-1f3fb-200d-1f373","👨🏼🍳":"1f468-1f3fc-200d-1f373","👨🏽🍳":"1f468-1f3fd-200d-1f373","👨🏾🍳":"1f468-1f3fe-200d-1f373","👨🏿🍳":"1f468-1f3ff-200d-1f373","👩🏻🍳":"1f469-1f3fb-200d-1f373","👩🏼🍳":"1f469-1f3fc-200d-1f373","👩🏽🍳":"1f469-1f3fd-200d-1f373","👩🏾🍳":"1f469-1f3fe-200d-1f373","👩🏿🍳":"1f469-1f3ff-200d-1f373","👨🏻🔧":"1f468-1f3fb-200d-1f527","👨🏼🔧":"1f468-1f3fc-200d-1f527","👨🏽🔧":"1f468-1f3fd-200d-1f527","👨🏾🔧":"1f468-1f3fe-200d-1f527","👨🏿🔧":"1f468-1f3ff-200d-1f527","👩🏻🔧":"1f469-1f3fb-200d-1f527","👩🏼🔧":"1f469-1f3fc-200d-1f527","👩🏽🔧":"1f469-1f3fd-200d-1f527","👩🏾🔧":"1f469-1f3fe-200d-1f527","👩🏿🔧":"1f469-1f3ff-200d-1f527","👨🏻🏭":"1f468-1f3fb-200d-1f3ed","👨🏼🏭":"1f468-1f3fc-200d-1f3ed","👨🏽🏭":"1f468-1f3fd-200d-1f3ed","👨🏾🏭":"1f468-1f3fe-200d-1f3ed","👨🏿🏭":"1f468-1f3ff-200d-1f3ed","👩🏻🏭":"1f469-1f3fb-200d-1f3ed","👩🏼🏭":"1f469-1f3fc-200d-1f3ed","👩🏽🏭":"1f469-1f3fd-200d-1f3ed","👩🏾🏭":"1f469-1f3fe-200d-1f3ed","👩🏿🏭":"1f469-1f3ff-200d-1f3ed","👨🏻💼":"1f468-1f3fb-200d-1f4bc","👨🏼💼":"1f468-1f3fc-200d-1f4bc","👨🏽💼":"1f468-1f3fd-200d-1f4bc","👨🏾💼":"1f468-1f3fe-200d-1f4bc","👨🏿💼":"1f468-1f3ff-200d-1f4bc","👩🏻💼":"1f469-1f3fb-200d-1f4bc","👩🏼💼":"1f469-1f3fc-200d-1f4bc","👩🏽💼":"1f469-1f3fd-200d-1f4bc","👩🏾💼":"1f469-1f3fe-200d-1f4bc","👩🏿💼":"1f469-1f3ff-200d-1f4bc","👨🏻🔬":"1f468-1f3fb-200d-1f52c","👨🏼🔬":"1f468-1f3fc-200d-1f52c","👨🏽🔬":"1f468-1f3fd-200d-1f52c","👨🏾🔬":"1f468-1f3fe-200d-1f52c","👨🏿🔬":"1f468-1f3ff-200d-1f52c","👩🏻🔬":"1f469-1f3fb-200d-1f52c","👩🏼🔬":"1f469-1f3fc-200d-1f52c","👩🏽🔬":"1f469-1f3fd-200d-1f52c","👩🏾🔬":"1f469-1f3fe-200d-1f52c","👩🏿🔬":"1f469-1f3ff-200d-1f52c","👨🏻💻":"1f468-1f3fb-200d-1f4bb","👨🏼💻":"1f468-1f3fc-200d-1f4bb","👨🏽💻":"1f468-1f3fd-200d-1f4bb","👨🏾💻":"1f468-1f3fe-200d-1f4bb","👨🏿💻":"1f468-1f3ff-200d-1f4bb","👩🏻💻":"1f469-1f3fb-200d-1f4bb","👩🏼💻":"1f469-1f3fc-200d-1f4bb","👩🏽💻":"1f469-1f3fd-200d-1f4bb","👩🏾💻":"1f469-1f3fe-200d-1f4bb","👩🏿💻":"1f469-1f3ff-200d-1f4bb","👨🏻🎤":"1f468-1f3fb-200d-1f3a4","👨🏼🎤":"1f468-1f3fc-200d-1f3a4","👨🏽🎤":"1f468-1f3fd-200d-1f3a4","👨🏾🎤":"1f468-1f3fe-200d-1f3a4","👨🏿🎤":"1f468-1f3ff-200d-1f3a4","👩🏻🎤":"1f469-1f3fb-200d-1f3a4","👩🏼🎤":"1f469-1f3fc-200d-1f3a4","👩🏽🎤":"1f469-1f3fd-200d-1f3a4","👩🏾🎤":"1f469-1f3fe-200d-1f3a4","👩🏿🎤":"1f469-1f3ff-200d-1f3a4","👨🏻🎨":"1f468-1f3fb-200d-1f3a8","👨🏼🎨":"1f468-1f3fc-200d-1f3a8","👨🏽🎨":"1f468-1f3fd-200d-1f3a8","👨🏾🎨":"1f468-1f3fe-200d-1f3a8","👨🏿🎨":"1f468-1f3ff-200d-1f3a8","👩🏻🎨":"1f469-1f3fb-200d-1f3a8","👩🏼🎨":"1f469-1f3fc-200d-1f3a8","👩🏽🎨":"1f469-1f3fd-200d-1f3a8","👩🏾🎨":"1f469-1f3fe-200d-1f3a8","👩🏿🎨":"1f469-1f3ff-200d-1f3a8","👨✈️":"1f468-200d-2708-fe0f","👨🏻✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈":"1f468-1f3ff-200d-2708-fe0f","👩✈️":"1f469-200d-2708-fe0f","👩🏻✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻🚀":"1f468-1f3fb-200d-1f680","👨🏼🚀":"1f468-1f3fc-200d-1f680","👨🏽🚀":"1f468-1f3fd-200d-1f680","👨🏾🚀":"1f468-1f3fe-200d-1f680","👨🏿🚀":"1f468-1f3ff-200d-1f680","👩🏻🚀":"1f469-1f3fb-200d-1f680","👩🏼🚀":"1f469-1f3fc-200d-1f680","👩🏽🚀":"1f469-1f3fd-200d-1f680","👩🏾🚀":"1f469-1f3fe-200d-1f680","👩🏿🚀":"1f469-1f3ff-200d-1f680","👨🏻🚒":"1f468-1f3fb-200d-1f692","👨🏼🚒":"1f468-1f3fc-200d-1f692","👨🏽🚒":"1f468-1f3fd-200d-1f692","👨🏾🚒":"1f468-1f3fe-200d-1f692","👨🏿🚒":"1f468-1f3ff-200d-1f692","👩🏻🚒":"1f469-1f3fb-200d-1f692","👩🏼🚒":"1f469-1f3fc-200d-1f692","👩🏽🚒":"1f469-1f3fd-200d-1f692","👩🏾🚒":"1f469-1f3fe-200d-1f692","👩🏿🚒":"1f469-1f3ff-200d-1f692","👮♂️":"1f46e-200d-2642-fe0f","👮🏻♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂":"1f46e-1f3ff-200d-2642-fe0f","👮♀️":"1f46e-200d-2640-fe0f","👮🏻♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀":"1f46e-1f3ff-200d-2640-fe0f","🕵♂️":"1f575-fe0f-200d-2642-fe0f","🕵️♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂":"1f575-1f3ff-200d-2642-fe0f","🕵♀️":"1f575-fe0f-200d-2640-fe0f","🕵️♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀":"1f575-1f3ff-200d-2640-fe0f","💂♂️":"1f482-200d-2642-fe0f","💂🏻♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂":"1f482-1f3ff-200d-2642-fe0f","💂♀️":"1f482-200d-2640-fe0f","💂🏻♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀":"1f482-1f3ff-200d-2640-fe0f","👷♂️":"1f477-200d-2642-fe0f","👷🏻♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂":"1f477-1f3ff-200d-2642-fe0f","👷♀️":"1f477-200d-2640-fe0f","👷🏻♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀":"1f477-1f3ff-200d-2640-fe0f","👳♂️":"1f473-200d-2642-fe0f","👳🏻♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂":"1f473-1f3ff-200d-2642-fe0f","👳♀️":"1f473-200d-2640-fe0f","👳🏻♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀":"1f473-1f3ff-200d-2640-fe0f","👱♂️":"1f471-200d-2642-fe0f","👱🏻♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂":"1f471-1f3ff-200d-2642-fe0f","👱♀️":"1f471-200d-2640-fe0f","👱🏻♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀":"1f471-1f3ff-200d-2640-fe0f","👨🏻🦰":"1f468-1f3fb-200d-1f9b0","👨🏼🦰":"1f468-1f3fc-200d-1f9b0","👨🏽🦰":"1f468-1f3fd-200d-1f9b0","👨🏾🦰":"1f468-1f3fe-200d-1f9b0","👨🏿🦰":"1f468-1f3ff-200d-1f9b0","👩🏻🦰":"1f469-1f3fb-200d-1f9b0","👩🏼🦰":"1f469-1f3fc-200d-1f9b0","👩🏽🦰":"1f469-1f3fd-200d-1f9b0","👩🏾🦰":"1f469-1f3fe-200d-1f9b0","👩🏿🦰":"1f469-1f3ff-200d-1f9b0","👨🏻🦱":"1f468-1f3fb-200d-1f9b1","👨🏼🦱":"1f468-1f3fc-200d-1f9b1","👨🏽🦱":"1f468-1f3fd-200d-1f9b1","👨🏾🦱":"1f468-1f3fe-200d-1f9b1","👨🏿🦱":"1f468-1f3ff-200d-1f9b1","👩🏻🦱":"1f469-1f3fb-200d-1f9b1","👩🏼🦱":"1f469-1f3fc-200d-1f9b1","👩🏽🦱":"1f469-1f3fd-200d-1f9b1","👩🏾🦱":"1f469-1f3fe-200d-1f9b1","👩🏿🦱":"1f469-1f3ff-200d-1f9b1","👨🏻🦲":"1f468-1f3fb-200d-1f9b2","👨🏼🦲":"1f468-1f3fc-200d-1f9b2","👨🏽🦲":"1f468-1f3fd-200d-1f9b2","👨🏾🦲":"1f468-1f3fe-200d-1f9b2","👨🏿🦲":"1f468-1f3ff-200d-1f9b2","👩🏻🦲":"1f469-1f3fb-200d-1f9b2","👩🏼🦲":"1f469-1f3fc-200d-1f9b2","👩🏽🦲":"1f469-1f3fd-200d-1f9b2","👩🏾🦲":"1f469-1f3fe-200d-1f9b2","👩🏿🦲":"1f469-1f3ff-200d-1f9b2","👨🏻🦳":"1f468-1f3fb-200d-1f9b3","👨🏼🦳":"1f468-1f3fc-200d-1f9b3","👨🏽🦳":"1f468-1f3fd-200d-1f9b3","👨🏾🦳":"1f468-1f3fe-200d-1f9b3","👨🏿🦳":"1f468-1f3ff-200d-1f9b3","👩🏻🦳":"1f469-1f3fb-200d-1f9b3","👩🏼🦳":"1f469-1f3fc-200d-1f9b3","👩🏽🦳":"1f469-1f3fd-200d-1f9b3","👩🏾🦳":"1f469-1f3fe-200d-1f9b3","👩🏿🦳":"1f469-1f3ff-200d-1f9b3","🦸♀️":"1f9b8-200d-2640-fe0f","🦸🏻♀":"1f9b8-1f3fb-200d-2640-fe0f","🦸🏼♀":"1f9b8-1f3fc-200d-2640-fe0f","🦸🏽♀":"1f9b8-1f3fd-200d-2640-fe0f","🦸🏾♀":"1f9b8-1f3fe-200d-2640-fe0f","🦸🏿♀":"1f9b8-1f3ff-200d-2640-fe0f","🦸♂️":"1f9b8-200d-2642-fe0f","🦸🏻♂":"1f9b8-1f3fb-200d-2642-fe0f","🦸🏼♂":"1f9b8-1f3fc-200d-2642-fe0f","🦸🏽♂":"1f9b8-1f3fd-200d-2642-fe0f","🦸🏾♂":"1f9b8-1f3fe-200d-2642-fe0f","🦸🏿♂":"1f9b8-1f3ff-200d-2642-fe0f","🦹♀️":"1f9b9-200d-2640-fe0f","🦹🏻♀":"1f9b9-1f3fb-200d-2640-fe0f","🦹🏼♀":"1f9b9-1f3fc-200d-2640-fe0f","🦹🏽♀":"1f9b9-1f3fd-200d-2640-fe0f","🦹🏾♀":"1f9b9-1f3fe-200d-2640-fe0f","🦹🏿♀":"1f9b9-1f3ff-200d-2640-fe0f","🦹♂️":"1f9b9-200d-2642-fe0f","🦹🏻♂":"1f9b9-1f3fb-200d-2642-fe0f","🦹🏼♂":"1f9b9-1f3fc-200d-2642-fe0f","🦹🏽♂":"1f9b9-1f3fd-200d-2642-fe0f","🦹🏾♂":"1f9b9-1f3fe-200d-2642-fe0f","🦹🏿♂":"1f9b9-1f3ff-200d-2642-fe0f","🧙♀️":"1f9d9-200d-2640-fe0f","🧙🏻♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙♂️":"1f9d9-200d-2642-fe0f","🧙🏻♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚♀️":"1f9da-200d-2640-fe0f","🧚🏻♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀":"1f9da-1f3ff-200d-2640-fe0f","🧚♂️":"1f9da-200d-2642-fe0f","🧚🏻♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂":"1f9da-1f3ff-200d-2642-fe0f","🧛♀️":"1f9db-200d-2640-fe0f","🧛🏻♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀":"1f9db-1f3ff-200d-2640-fe0f","🧛♂️":"1f9db-200d-2642-fe0f","🧛🏻♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂":"1f9db-1f3ff-200d-2642-fe0f","🧜♀️":"1f9dc-200d-2640-fe0f","🧜🏻♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜♂️":"1f9dc-200d-2642-fe0f","🧜🏻♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝♀️":"1f9dd-200d-2640-fe0f","🧝🏻♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝♂️":"1f9dd-200d-2642-fe0f","🧝🏻♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧞♂️":"1f9de-200d-2642-fe0f","🧟♀️":"1f9df-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🙍♂️":"1f64d-200d-2642-fe0f","🙍🏻♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂":"1f64d-1f3ff-200d-2642-fe0f","🙍♀️":"1f64d-200d-2640-fe0f","🙍🏻♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀":"1f64d-1f3ff-200d-2640-fe0f","🙎♂️":"1f64e-200d-2642-fe0f","🙎🏻♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂":"1f64e-1f3ff-200d-2642-fe0f","🙎♀️":"1f64e-200d-2640-fe0f","🙎🏻♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀":"1f64e-1f3ff-200d-2640-fe0f","🙅♂️":"1f645-200d-2642-fe0f","🙅🏻♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂":"1f645-1f3ff-200d-2642-fe0f","🙅♀️":"1f645-200d-2640-fe0f","🙅🏻♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀":"1f645-1f3ff-200d-2640-fe0f","🙆♂️":"1f646-200d-2642-fe0f","🙆🏻♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂":"1f646-1f3ff-200d-2642-fe0f","🙆♀️":"1f646-200d-2640-fe0f","🙆🏻♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀":"1f646-1f3ff-200d-2640-fe0f","💁♂️":"1f481-200d-2642-fe0f","💁🏻♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂":"1f481-1f3ff-200d-2642-fe0f","💁♀️":"1f481-200d-2640-fe0f","💁🏻♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀":"1f481-1f3ff-200d-2640-fe0f","🙋♂️":"1f64b-200d-2642-fe0f","🙋🏻♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂":"1f64b-1f3ff-200d-2642-fe0f","🙋♀️":"1f64b-200d-2640-fe0f","🙋🏻♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀":"1f64b-1f3ff-200d-2640-fe0f","🙇♂️":"1f647-200d-2642-fe0f","🙇🏻♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂":"1f647-1f3ff-200d-2642-fe0f","🙇♀️":"1f647-200d-2640-fe0f","🙇🏻♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀":"1f647-1f3ff-200d-2640-fe0f","🤦♂️":"1f926-200d-2642-fe0f","🤦🏻♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂":"1f926-1f3ff-200d-2642-fe0f","🤦♀️":"1f926-200d-2640-fe0f","🤦🏻♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀":"1f926-1f3ff-200d-2640-fe0f","🤷♂️":"1f937-200d-2642-fe0f","🤷🏻♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂":"1f937-1f3ff-200d-2642-fe0f","🤷♀️":"1f937-200d-2640-fe0f","🤷🏻♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀":"1f937-1f3ff-200d-2640-fe0f","💆♂️":"1f486-200d-2642-fe0f","💆🏻♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂":"1f486-1f3ff-200d-2642-fe0f","💆♀️":"1f486-200d-2640-fe0f","💆🏻♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀":"1f486-1f3ff-200d-2640-fe0f","💇♂️":"1f487-200d-2642-fe0f","💇🏻♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂":"1f487-1f3ff-200d-2642-fe0f","💇♀️":"1f487-200d-2640-fe0f","💇🏻♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀":"1f487-1f3ff-200d-2640-fe0f","🚶♂️":"1f6b6-200d-2642-fe0f","🚶🏻♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶♀️":"1f6b6-200d-2640-fe0f","🚶🏻♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃♂️":"1f3c3-200d-2642-fe0f","🏃🏻♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃♀️":"1f3c3-200d-2640-fe0f","🏃🏻♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀":"1f3c3-1f3ff-200d-2640-fe0f","👯♂️":"1f46f-200d-2642-fe0f","👯♀️":"1f46f-200d-2640-fe0f","🧖♀️":"1f9d6-200d-2640-fe0f","🧖🏻♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖♂️":"1f9d6-200d-2642-fe0f","🧖🏻♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗♀️":"1f9d7-200d-2640-fe0f","🧗🏻♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗♂️":"1f9d7-200d-2642-fe0f","🧗🏻♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘♀️":"1f9d8-200d-2640-fe0f","🧘🏻♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘♂️":"1f9d8-200d-2642-fe0f","🧘🏻♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄♂️":"1f3c4-200d-2642-fe0f","🏄🏻♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄♀️":"1f3c4-200d-2640-fe0f","🏄🏻♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣♂️":"1f6a3-200d-2642-fe0f","🚣🏻♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣♀️":"1f6a3-200d-2640-fe0f","🚣🏻♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊♂️":"1f3ca-200d-2642-fe0f","🏊🏻♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊♀️":"1f3ca-200d-2640-fe0f","🏊🏻♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹♂️":"26f9-fe0f-200d-2642-fe0f","⛹️♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂":"26f9-1f3ff-200d-2642-fe0f","⛹♀️":"26f9-fe0f-200d-2640-fe0f","⛹️♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀":"26f9-1f3ff-200d-2640-fe0f","🏋♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴♂️":"1f6b4-200d-2642-fe0f","🚴🏻♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴♀️":"1f6b4-200d-2640-fe0f","🚴🏻♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵♂️":"1f6b5-200d-2642-fe0f","🚵🏻♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵♀️":"1f6b5-200d-2640-fe0f","🚵🏻♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸♂️":"1f938-200d-2642-fe0f","🤸🏻♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂":"1f938-1f3ff-200d-2642-fe0f","🤸♀️":"1f938-200d-2640-fe0f","🤸🏻♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀":"1f938-1f3ff-200d-2640-fe0f","🤼♂️":"1f93c-200d-2642-fe0f","🤼♀️":"1f93c-200d-2640-fe0f","🤽♂️":"1f93d-200d-2642-fe0f","🤽🏻♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂":"1f93d-1f3ff-200d-2642-fe0f","🤽♀️":"1f93d-200d-2640-fe0f","🤽🏻♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀":"1f93d-1f3ff-200d-2640-fe0f","🤾♂️":"1f93e-200d-2642-fe0f","🤾🏻♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂":"1f93e-1f3ff-200d-2642-fe0f","🤾♀️":"1f93e-200d-2640-fe0f","🤾🏻♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀":"1f93e-1f3ff-200d-2640-fe0f","🤹♂️":"1f939-200d-2642-fe0f","🤹🏻♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂":"1f939-1f3ff-200d-2642-fe0f","🤹♀️":"1f939-200d-2640-fe0f","🤹🏻♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀":"1f939-1f3ff-200d-2640-fe0f","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","🏳️🌈":"1f3f3-fe0f-200d-1f308","🏴☠️":"1f3f4-200d-2620-fe0f","👨🏻⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀️":"1f471-1f3ff-200d-2640-fe0f","🦸🏻♀️":"1f9b8-1f3fb-200d-2640-fe0f","🦸🏼♀️":"1f9b8-1f3fc-200d-2640-fe0f","🦸🏽♀️":"1f9b8-1f3fd-200d-2640-fe0f","🦸🏾♀️":"1f9b8-1f3fe-200d-2640-fe0f","🦸🏿♀️":"1f9b8-1f3ff-200d-2640-fe0f","🦸🏻♂️":"1f9b8-1f3fb-200d-2642-fe0f","🦸🏼♂️":"1f9b8-1f3fc-200d-2642-fe0f","🦸🏽♂️":"1f9b8-1f3fd-200d-2642-fe0f","🦸🏾♂️":"1f9b8-1f3fe-200d-2642-fe0f","🦸🏿♂️":"1f9b8-1f3ff-200d-2642-fe0f","🦹🏻♀️":"1f9b9-1f3fb-200d-2640-fe0f","🦹🏼♀️":"1f9b9-1f3fc-200d-2640-fe0f","🦹🏽♀️":"1f9b9-1f3fd-200d-2640-fe0f","🦹🏾♀️":"1f9b9-1f3fe-200d-2640-fe0f","🦹🏿♀️":"1f9b9-1f3ff-200d-2640-fe0f","🦹🏻♂️":"1f9b9-1f3fb-200d-2642-fe0f","🦹🏼♂️":"1f9b9-1f3fc-200d-2642-fe0f","🦹🏽♂️":"1f9b9-1f3fd-200d-2642-fe0f","🦹🏾♂️":"1f9b9-1f3fe-200d-2642-fe0f","🦹🏿♂️":"1f9b9-1f3ff-200d-2642-fe0f","🧙🏻♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀️":"1f939-1f3ff-200d-2640-fe0f","👩❤👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤👩":"1f469-200d-2764-fe0f-200d-1f469","👨👩👦":"1f468-200d-1f469-200d-1f466","👨👩👧":"1f468-200d-1f469-200d-1f467","👨👨👦":"1f468-200d-1f468-200d-1f466","👨👨👧":"1f468-200d-1f468-200d-1f467","👩👩👦":"1f469-200d-1f469-200d-1f466","👩👩👧":"1f469-200d-1f469-200d-1f467","👨👦👦":"1f468-200d-1f466-200d-1f466","👨👧👦":"1f468-200d-1f467-200d-1f466","👨👧👧":"1f468-200d-1f467-200d-1f467","👩👦👦":"1f469-200d-1f466-200d-1f466","👩👧👦":"1f469-200d-1f467-200d-1f466","👩👧👧":"1f469-200d-1f467-200d-1f467","👁️🗨️":"1f441-200d-1f5e8","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","👩❤💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨👩👧👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨👩👦👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨👩👧👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨👨👧👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨👨👦👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨👨👧👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩👩👧👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩👩👦👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩👩👧👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩❤️💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤️💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤️💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"} \ No newline at end of file diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js new file mode 100644 index 000000000..45086fc4c --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_mart_data_light.js @@ -0,0 +1,41 @@ +// The output of this module is designed to mimic emoji-mart's +// "data" object, such that we can use it for a light version of emoji-mart's +// emojiIndex.search functionality. +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed'); + +const emojis = {}; + +// decompress +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + let [ + filenameData, // eslint-disable-line no-unused-vars + searchData, + ] = shortCodesToEmojiData[shortCode]; + let [ + native, + short_names, + search, + unified, + ] = searchData; + + if (!unified) { + // unified name can be derived from unicodeToUnifiedName + unified = unicodeToUnifiedName(native); + } + + short_names = [shortCode].concat(short_names); + emojis[shortCode] = { + native, + search, + short_names, + unified, + }; +}); + +module.exports = { + emojis, + skins, + categories, + short_names, +}; diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js new file mode 100644 index 000000000..164fdcc0b --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_mart_search_light.js @@ -0,0 +1,185 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js + +import data from './emoji_mart_data_light'; +import { getData, getSanitizedData, uniq, intersect } from './emoji_utils'; + +let originalPool = {}; +let index = {}; +let emojisList = {}; +let emoticonsList = {}; +let customEmojisList = []; + +for (let emoji in data.emojis) { + let emojiData = data.emojis[emoji]; + let { short_names, emoticons } = emojiData; + let id = short_names[0]; + + if (emoticons) { + emoticons.forEach(emoticon => { + if (emoticonsList[emoticon]) { + return; + } + + emoticonsList[emoticon] = id; + }); + } + + emojisList[id] = getSanitizedData(id); + originalPool[id] = emojiData; +} + +function clearCustomEmojis(pool) { + customEmojisList.forEach((emoji) => { + let emojiId = emoji.id || emoji.short_names[0]; + + delete pool[emojiId]; + delete emojisList[emojiId]; + }); +} + +function addCustomToPool(custom, pool) { + if (customEmojisList.length) clearCustomEmojis(pool); + + custom.forEach((emoji) => { + let emojiId = emoji.id || emoji.short_names[0]; + + if (emojiId && !pool[emojiId]) { + pool[emojiId] = getData(emoji); + emojisList[emojiId] = getSanitizedData(emoji); + } + }); + + customEmojisList = custom; + index = {}; +} + +function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) { + if (custom !== undefined) { + if (customEmojisList !== custom) + addCustomToPool(custom, originalPool); + } else { + custom = []; + } + + maxResults = maxResults || 75; + include = include || []; + exclude = exclude || []; + + let results = null, + pool = originalPool; + + if (value.length) { + if (value === '-' || value === '-1') { + return [emojisList['-1']]; + } + + let values = value.toLowerCase().split(/[\s|,|\-|_]+/), + allResults = []; + + if (values.length > 2) { + values = [values[0], values[1]]; + } + + if (include.length || exclude.length) { + pool = {}; + + data.categories.forEach(category => { + let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; + let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; + if (!isIncluded || isExcluded) { + return; + } + + category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]); + }); + + if (custom.length) { + let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true; + let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false; + if (customIsIncluded && !customIsExcluded) { + addCustomToPool(custom, pool); + } + } + } + + const searchValue = (value) => { + let aPool = pool, + aIndex = index, + length = 0; + + for (let charIndex = 0; charIndex < value.length; charIndex++) { + const char = value[charIndex]; + length++; + + aIndex[char] = aIndex[char] || {}; + aIndex = aIndex[char]; + + if (!aIndex.results) { + let scores = {}; + + aIndex.results = []; + aIndex.pool = {}; + + for (let id in aPool) { + let emoji = aPool[id], + { search } = emoji, + sub = value.substr(0, length), + subIndex = search.indexOf(sub); + + if (subIndex !== -1) { + let score = subIndex + 1; + if (sub === id) score = 0; + + aIndex.results.push(emojisList[id]); + aIndex.pool[id] = emoji; + + scores[id] = score; + } + } + + aIndex.results.sort((a, b) => { + let aScore = scores[a.id], + bScore = scores[b.id]; + + return aScore - bScore; + }); + } + + aPool = aIndex.pool; + } + + return aIndex.results; + }; + + if (values.length > 1) { + results = searchValue(value); + } else { + results = []; + } + + allResults = values.map(searchValue).filter(a => a); + + if (allResults.length > 1) { + allResults = intersect.apply(null, allResults); + } else if (allResults.length) { + allResults = allResults[0]; + } + + results = uniq(results.concat(allResults)); + } + + if (results) { + if (emojisToShowFilter) { + results = results.filter((result) => emojisToShowFilter(data.emojis[result.id])); + } + + if (results && results.length > maxResults) { + results = results.slice(0, maxResults); + } + } + + return results; +} + +export { search }; diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_picker.js b/app/javascript/flavours/glitch/util/emoji/emoji_picker.js new file mode 100644 index 000000000..044d38cb2 --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_picker.js @@ -0,0 +1,7 @@ +import Picker from 'emoji-mart/dist-es/components/picker/picker'; +import Emoji from 'emoji-mart/dist-es/components/emoji/emoji'; + +export { + Picker, + Emoji, +}; diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js b/app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js new file mode 100644 index 000000000..918684c31 --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_unicode_mapping_light.js @@ -0,0 +1,35 @@ +// A mapping of unicode strings to an object containing the filename +// (i.e. the svg filename) and a shortCode intended to be shown +// as a "title" attribute in an HTML element (aka tooltip). + +const [ + shortCodesToEmojiData, + skins, // eslint-disable-line no-unused-vars + categories, // eslint-disable-line no-unused-vars + short_names, // eslint-disable-line no-unused-vars + emojisWithoutShortCodes, +] = require('./emoji_compressed'); +const { unicodeToFilename } = require('./unicode_to_filename'); + +// decompress +const unicodeMapping = {}; + +function processEmojiMapData(emojiMapData, shortCode) { + let [ native, filename ] = emojiMapData; + if (!filename) { + // filename name can be derived from unicodeToFilename + filename = unicodeToFilename(native); + } + unicodeMapping[native] = { + shortCode: shortCode, + filename: filename, + }; +} + +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + let [ filenameData ] = shortCodesToEmojiData[shortCode]; + filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode)); +}); +emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData)); + +module.exports = unicodeMapping; diff --git a/app/javascript/flavours/glitch/util/emoji/emoji_utils.js b/app/javascript/flavours/glitch/util/emoji/emoji_utils.js new file mode 100644 index 000000000..dbf725c1f --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_utils.js @@ -0,0 +1,258 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js + +import data from './emoji_mart_data_light'; + +const buildSearch = (data) => { + const search = []; + + let addToSearch = (strings, split) => { + if (!strings) { + return; + } + + (Array.isArray(strings) ? strings : [strings]).forEach((string) => { + (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { + s = s.toLowerCase(); + + if (search.indexOf(s) === -1) { + search.push(s); + } + }); + }); + }; + + addToSearch(data.short_names, true); + addToSearch(data.name, true); + addToSearch(data.keywords, false); + addToSearch(data.emoticons, false); + + return search.join(','); +}; + +const _String = String; + +const stringFromCodePoint = _String.fromCodePoint || function () { + let MAX_SIZE = 0x4000; + let codeUnits = []; + let highSurrogate; + let lowSurrogate; + let index = -1; + let length = arguments.length; + if (!length) { + return ''; + } + let result = ''; + while (++index < length) { + let codePoint = Number(arguments[index]); + if ( + !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` + codePoint < 0 || // not a valid Unicode code point + codePoint > 0x10FFFF || // not a valid Unicode code point + Math.floor(codePoint) !== codePoint // not an integer + ) { + throw RangeError('Invalid code point: ' + codePoint); + } + if (codePoint <= 0xFFFF) { // BMP code point + codeUnits.push(codePoint); + } else { // Astral code point; split in surrogate halves + // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint -= 0x10000; + highSurrogate = (codePoint >> 10) + 0xD800; + lowSurrogate = (codePoint % 0x400) + 0xDC00; + codeUnits.push(highSurrogate, lowSurrogate); + } + if (index + 1 === length || codeUnits.length > MAX_SIZE) { + result += String.fromCharCode.apply(null, codeUnits); + codeUnits.length = 0; + } + } + return result; +}; + + +const _JSON = JSON; + +const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; +const SKINS = [ + '1F3FA', '1F3FB', '1F3FC', + '1F3FD', '1F3FE', '1F3FF', +]; + +function unifiedToNative(unified) { + let unicodes = unified.split('-'), + codePoints = unicodes.map((u) => `0x${u}`); + + return stringFromCodePoint.apply(null, codePoints); +} + +function sanitize(emoji) { + let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, + id = emoji.id || short_names[0], + colons = `:${id}:`; + + if (custom) { + return { + id, + name, + colons, + emoticons, + custom, + imageUrl, + }; + } + + if (skin_tone) { + colons += `:skin-tone-${skin_tone}:`; + } + + return { + id, + name, + colons, + emoticons, + unified: unified.toLowerCase(), + skin: skin_tone || (skin_variations ? 1 : null), + native: unifiedToNative(unified), + }; +} + +function getSanitizedData() { + return sanitize(getData(...arguments)); +} + +function getData(emoji, skin, set) { + let emojiData = {}; + + if (typeof emoji === 'string') { + let matches = emoji.match(COLONS_REGEX); + + if (matches) { + emoji = matches[1]; + + if (matches[2]) { + skin = parseInt(matches[2]); + } + } + + if (data.short_names.hasOwnProperty(emoji)) { + emoji = data.short_names[emoji]; + } + + if (data.emojis.hasOwnProperty(emoji)) { + emojiData = data.emojis[emoji]; + } + } else if (emoji.id) { + if (data.short_names.hasOwnProperty(emoji.id)) { + emoji.id = data.short_names[emoji.id]; + } + + if (data.emojis.hasOwnProperty(emoji.id)) { + emojiData = data.emojis[emoji.id]; + skin = skin || emoji.skin; + } + } + + if (!Object.keys(emojiData).length) { + emojiData = emoji; + emojiData.custom = true; + + if (!emojiData.search) { + emojiData.search = buildSearch(emoji); + } + } + + emojiData.emoticons = emojiData.emoticons || []; + emojiData.variations = emojiData.variations || []; + + if (emojiData.skin_variations && skin > 1 && set) { + emojiData = JSON.parse(_JSON.stringify(emojiData)); + + let skinKey = SKINS[skin - 1], + variationData = emojiData.skin_variations[skinKey]; + + if (!variationData.variations && emojiData.variations) { + delete emojiData.variations; + } + + if (variationData[`has_img_${set}`]) { + emojiData.skin_tone = skin; + + for (let k in variationData) { + let v = variationData[k]; + emojiData[k] = v; + } + } + } + + if (emojiData.variations && emojiData.variations.length) { + emojiData = JSON.parse(_JSON.stringify(emojiData)); + emojiData.unified = emojiData.variations.shift(); + } + + return emojiData; +} + +function uniq(arr) { + return arr.reduce((acc, item) => { + if (acc.indexOf(item) === -1) { + acc.push(item); + } + return acc; + }, []); +} + +function intersect(a, b) { + const uniqA = uniq(a); + const uniqB = uniq(b); + + return uniqA.filter(item => uniqB.indexOf(item) >= 0); +} + +function deepMerge(a, b) { + let o = {}; + + for (let key in a) { + let originalValue = a[key], + value = originalValue; + + if (b.hasOwnProperty(key)) { + value = b[key]; + } + + if (typeof value === 'object') { + value = deepMerge(originalValue, value); + } + + o[key] = value; + } + + return o; +} + +// https://github.com/sonicdoe/measure-scrollbar +function measureScrollbar() { + const div = document.createElement('div'); + + div.style.width = '100px'; + div.style.height = '100px'; + div.style.overflow = 'scroll'; + div.style.position = 'absolute'; + div.style.top = '-9999px'; + + document.body.appendChild(div); + const scrollbarWidth = div.offsetWidth - div.clientWidth; + document.body.removeChild(div); + + return scrollbarWidth; +} + +export { + getData, + getSanitizedData, + uniq, + intersect, + deepMerge, + unifiedToNative, + measureScrollbar, +}; diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js new file mode 100644 index 000000000..82a1ef89c --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/index.js @@ -0,0 +1,100 @@ +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import unicodeMapping from './emoji_unicode_mapping_light'; +import Trie from 'substring-trie'; + +const trie = new Trie(Object.keys(unicodeMapping)); + +const assetHost = process.env.CDN_HOST || ''; + +const emojify = (str, customEmojis = {}) => { + const tagCharsWithoutEmojis = '<&'; + const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&'; + let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0; + for (;;) { + let match, i = 0, tag; + while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) { + i += str.codePointAt(i) < 65536 ? 1 : 2; + } + let rend, replacement = ''; + if (i === str.length) { + break; + } else if (str[i] === ':') { + if (!(() => { + rend = str.indexOf(':', i + 1) + 1; + if (!rend) return false; // no pair of ':' + const lt = str.indexOf('<', i + 1); + if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':' + const shortname = str.slice(i, rend); + // now got a replacee as ':shortname:' + // if you want additional emoji handler, add statements below which set replacement and return true. + if (shortname in customEmojis) { + const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url; + replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`; + return true; + } + return false; + })()) rend = ++i; + } else if (tag >= 0) { // <, & + rend = str.indexOf('>;'[tag], i + 1) + 1; + if (!rend) { + break; + } + if (tag === 0) { + if (invisible) { + if (str[i + 1] === '/') { // closing tag + if (!--invisible) { + tagChars = tagCharsWithEmojis; + } + } else if (str[rend - 2] !== '/') { // opening tag + invisible++; + } + } else { + if (str.startsWith('<span class="invisible">', i)) { + // avoid emojifying on invisible text + invisible = 1; + tagChars = tagCharsWithoutEmojis; + } + } + } + i = rend; + } else { // matched to unicode emoji + const { filename, shortCode } = unicodeMapping[match]; + const title = shortCode ? `:${shortCode}:` : ''; + replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; + rend = i + match.length; + // If the matched character was followed by VS15 (for selecting text presentation), skip it. + if (str.codePointAt(rend) === 65038) { + rend += 1; + } + } + rtn += str.slice(0, i) + replacement; + str = str.slice(rend); + } + return rtn + str; +}; + +export default emojify; +export { unicodeMapping }; + +export const buildCustomEmojis = (customEmojis) => { + const emojis = []; + + customEmojis.forEach(emoji => { + const shortcode = emoji.get('shortcode'); + const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url'); + const name = shortcode.replace(':', ''); + + emojis.push({ + id: name, + name, + short_names: [name], + text: '', + emoticons: [], + keywords: [name], + imageUrl: url, + custom: true, + }); + }); + + return emojis; +}; diff --git a/app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js b/app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js new file mode 100644 index 000000000..c75c4cd7d --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/unicode_to_filename.js @@ -0,0 +1,26 @@ +// taken from: +// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 +exports.unicodeToFilename = (str) => { + let result = ''; + let charCode = 0; + let p = 0; + let i = 0; + while (i < str.length) { + charCode = str.charCodeAt(i++); + if (p) { + if (result.length > 0) { + result += '-'; + } + result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16); + p = 0; + } else if (0xD800 <= charCode && charCode <= 0xDBFF) { + p = charCode; + } else { + if (result.length > 0) { + result += '-'; + } + result += charCode.toString(16); + } + } + return result; +}; diff --git a/app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js b/app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js new file mode 100644 index 000000000..808ac197e --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/unicode_to_unified_name.js @@ -0,0 +1,17 @@ +function padLeft(str, num) { + while (str.length < num) { + str = '0' + str; + } + return str; +} + +exports.unicodeToUnifiedName = (str) => { + let output = ''; + for (let i = 0; i < str.length; i += 2) { + if (i > 0) { + output += '-'; + } + output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4); + } + return output; +}; diff --git a/app/javascript/flavours/glitch/util/extra_polyfills.js b/app/javascript/flavours/glitch/util/extra_polyfills.js new file mode 100644 index 000000000..3acc55abd --- /dev/null +++ b/app/javascript/flavours/glitch/util/extra_polyfills.js @@ -0,0 +1,5 @@ +import 'intersection-observer'; +import 'requestidlecallback'; +import objectFitImages from 'object-fit-images'; + +objectFitImages(); diff --git a/app/javascript/flavours/glitch/util/fullscreen.js b/app/javascript/flavours/glitch/util/fullscreen.js new file mode 100644 index 000000000..cf5d0cf98 --- /dev/null +++ b/app/javascript/flavours/glitch/util/fullscreen.js @@ -0,0 +1,46 @@ +// APIs for normalizing fullscreen operations. Note that Edge uses +// the WebKit-prefixed APIs currently (as of Edge 16). + +export const isFullscreen = () => document.fullscreenElement || + document.webkitFullscreenElement || + document.mozFullScreenElement; + +export const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } +}; + +export const requestFullscreen = el => { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } else if (el.mozRequestFullScreen) { + el.mozRequestFullScreen(); + } +}; + +export const attachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.addEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.addEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.addEventListener('mozfullscreenchange', listener); + } +}; + +export const detachFullscreenListener = (listener) => { + if ('onfullscreenchange' in document) { + document.removeEventListener('fullscreenchange', listener); + } else if ('onwebkitfullscreenchange' in document) { + document.removeEventListener('webkitfullscreenchange', listener); + } else if ('onmozfullscreenchange' in document) { + document.removeEventListener('mozfullscreenchange', listener); + } +}; diff --git a/app/javascript/flavours/glitch/util/get_rect_from_entry.js b/app/javascript/flavours/glitch/util/get_rect_from_entry.js new file mode 100644 index 000000000..c266cd7dc --- /dev/null +++ b/app/javascript/flavours/glitch/util/get_rect_from_entry.js @@ -0,0 +1,21 @@ + +// Get the bounding client rect from an IntersectionObserver entry. +// This is to work around a bug in Chrome: https://crbug.com/737228 + +let hasBoundingRectBug; + +function getRectFromEntry(entry) { + if (typeof hasBoundingRectBug !== 'boolean') { + const boundingRect = entry.target.getBoundingClientRect(); + const observerRect = entry.boundingClientRect; + hasBoundingRectBug = boundingRect.height !== observerRect.height || + boundingRect.top !== observerRect.top || + boundingRect.width !== observerRect.width || + boundingRect.bottom !== observerRect.bottom || + boundingRect.left !== observerRect.left || + boundingRect.right !== observerRect.right; + } + return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect; +} + +export default getRectFromEntry; diff --git a/app/javascript/flavours/glitch/util/hashtag.js b/app/javascript/flavours/glitch/util/hashtag.js new file mode 100644 index 000000000..9b663487f --- /dev/null +++ b/app/javascript/flavours/glitch/util/hashtag.js @@ -0,0 +1,8 @@ +export function recoverHashtags (recognizedTags, text) { + return recognizedTags.map(tag => { + const re = new RegExp(`(?:^|[^\/\)\w])#(${tag.name})`, 'i'); + const matched_hashtag = text.match(re); + return matched_hashtag ? matched_hashtag[1] : null; + } + ).filter(x => x !== null); +} diff --git a/app/javascript/flavours/glitch/util/html.js b/app/javascript/flavours/glitch/util/html.js new file mode 100644 index 000000000..5159df9db --- /dev/null +++ b/app/javascript/flavours/glitch/util/html.js @@ -0,0 +1,5 @@ +export const unescapeHTML = (html) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, ''); + return wrapper.textContent; +}; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js new file mode 100644 index 000000000..62588eeaa --- /dev/null +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -0,0 +1,31 @@ +const element = document.getElementById('initial-state'); +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]; + +export const reduceMotion = getMeta('reduce_motion'); +export const autoPlayGif = getMeta('auto_play_gif'); +export const displaySensitiveMedia = getMeta('display_sensitive_media'); +export const displayMedia = getMeta('display_media') || (getMeta('display_sensitive_media') ? 'show_all' : 'default'); +export const unfollowModal = getMeta('unfollow_modal'); +export const boostModal = getMeta('boost_modal'); +export const favouriteModal = getMeta('favourite_modal'); +export const deleteModal = getMeta('delete_modal'); +export const me = getMeta('me'); +export const searchEnabled = getMeta('search_enabled'); +export const maxChars = (initialState && initialState.max_toot_chars) || 500; +export const pollLimits = (initialState && initialState.poll_limits); +export const invitesEnabled = getMeta('invites_enabled'); +export const version = getMeta('version'); +export const mascot = getMeta('mascot'); +export const isStaff = getMeta('is_staff'); + +export default initialState; diff --git a/app/javascript/flavours/glitch/util/intersection_observer_wrapper.js b/app/javascript/flavours/glitch/util/intersection_observer_wrapper.js new file mode 100644 index 000000000..2b24c6583 --- /dev/null +++ b/app/javascript/flavours/glitch/util/intersection_observer_wrapper.js @@ -0,0 +1,57 @@ +// Wrapper for IntersectionObserver in order to make working with it +// a bit easier. We also follow this performance advice: +// "If you need to observe multiple elements, it is both possible and +// advised to observe multiple elements using the same IntersectionObserver +// instance by calling observe() multiple times." +// https://developers.google.com/web/updates/2016/04/intersectionobserver + +class IntersectionObserverWrapper { + + callbacks = {}; + observerBacklog = []; + observer = null; + + connect (options) { + const onIntersection = (entries) => { + entries.forEach(entry => { + const id = entry.target.getAttribute('data-id'); + if (this.callbacks[id]) { + this.callbacks[id](entry); + } + }); + }; + + this.observer = new IntersectionObserver(onIntersection, options); + this.observerBacklog.forEach(([ id, node, callback ]) => { + this.observe(id, node, callback); + }); + this.observerBacklog = null; + } + + observe (id, node, callback) { + if (!this.observer) { + this.observerBacklog.push([ id, node, callback ]); + } else { + this.callbacks[id] = callback; + this.observer.observe(node); + } + } + + unobserve (id, node) { + if (this.observer) { + delete this.callbacks[id]; + this.observer.unobserve(node); + } + } + + disconnect () { + if (this.observer) { + this.callbacks = {}; + this.observer.disconnect(); + this.observer = null; + } + } + +} + +export default IntersectionObserverWrapper; diff --git a/app/javascript/flavours/glitch/util/is_mobile.js b/app/javascript/flavours/glitch/util/is_mobile.js new file mode 100644 index 000000000..80e8e0a8a --- /dev/null +++ b/app/javascript/flavours/glitch/util/is_mobile.js @@ -0,0 +1,34 @@ +import detectPassiveEvents from 'detect-passive-events'; + +const LAYOUT_BREAKPOINT = 630; + +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; + +let userTouching = false; +let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +function touchListener() { + userTouching = true; + window.removeEventListener('touchstart', touchListener, listenerOptions); +} + +window.addEventListener('touchstart', touchListener, listenerOptions); + +export function isUserTouching() { + return userTouching; +} + +export function isIOS() { + return iOS; +}; diff --git a/app/javascript/flavours/glitch/util/js_helpers.js b/app/javascript/flavours/glitch/util/js_helpers.js new file mode 100644 index 000000000..2ebd5b6c5 --- /dev/null +++ b/app/javascript/flavours/glitch/util/js_helpers.js @@ -0,0 +1,5 @@ +// This function returns the new value unless it is `null` or +// `undefined`, in which case it returns the old one. +export function overwrite (oldVal, newVal) { + return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal; +} diff --git a/app/javascript/flavours/glitch/util/load_polyfills.js b/app/javascript/flavours/glitch/util/load_polyfills.js new file mode 100644 index 000000000..8cb81c1a6 --- /dev/null +++ b/app/javascript/flavours/glitch/util/load_polyfills.js @@ -0,0 +1,41 @@ +// Convenience function to load polyfills and return a promise when it's done. +// If there are no polyfills, then this is just Promise.resolve() which means +// it will execute in the same tick of the event loop (i.e. near-instant). + +function importBasePolyfills() { + return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills'); +} + +function importExtraPolyfills() { + return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills'); +} + +function loadPolyfills() { + const needsBasePolyfills = !( + Array.prototype.includes && + HTMLCanvasElement.prototype.toBlob && + window.Intl && + Number.isNaN && + Object.assign && + Object.values && + window.Symbol + ); + + // Latest version of Firefox and Safari do not have IntersectionObserver. + // Edge does not have requestIdleCallback and object-fit CSS property. + // This avoids shipping them all the polyfills. + const needsExtraPolyfills = !( + window.IntersectionObserver && + window.IntersectionObserverEntry && + 'isIntersecting' in IntersectionObserverEntry.prototype && + window.requestIdleCallback && + 'object-fit' in (new Image()).style + ); + + return Promise.all([ + needsBasePolyfills && importBasePolyfills(), + needsExtraPolyfills && importExtraPolyfills(), + ]); +} + +export default loadPolyfills; diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js new file mode 100644 index 000000000..b76826481 --- /dev/null +++ b/app/javascript/flavours/glitch/util/main.js @@ -0,0 +1,34 @@ +import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications'; +import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import ready from './ready'; + +const perf = require('./performance'); + +function main() { + perf.start('main()'); + + if (window.history && history.replaceState) { + const { pathname, search, hash } = window.location; + const path = pathname + search + hash; + if (!(/^\/web[$/]/).test(path)) { + history.replaceState(null, document.title, `/web${path}`); + } + } + + ready(() => { + const mountNode = document.getElementById('mastodon'); + const props = JSON.parse(mountNode.getAttribute('data-props')); + + ReactDOM.render(<Mastodon {...props} />, mountNode); + if (process.env.NODE_ENV === 'production') { + // avoid offline in dev mode because it's harder to debug + require('offline-plugin/runtime').install(); + store.dispatch(registerPushNotifications.register()); + } + perf.stop('main()'); + }); +} + +export default main; diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js new file mode 100644 index 000000000..fdd8269ae --- /dev/null +++ b/app/javascript/flavours/glitch/util/numbers.js @@ -0,0 +1,10 @@ +import React, { Fragment } from 'react'; +import { FormattedNumber } from 'react-intl'; + +export const shortNumberFormat = number => { + if (number < 1000) { + return <FormattedNumber value={number} />; + } else { + return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; + } +}; diff --git a/app/javascript/flavours/glitch/util/optional_motion.js b/app/javascript/flavours/glitch/util/optional_motion.js new file mode 100644 index 000000000..eecb6634e --- /dev/null +++ b/app/javascript/flavours/glitch/util/optional_motion.js @@ -0,0 +1,5 @@ +import { reduceMotion } from 'flavours/glitch/util/initial_state'; +import ReducedMotion from './reduced_motion'; +import Motion from 'react-motion/lib/Motion'; + +export default reduceMotion ? ReducedMotion : Motion; diff --git a/app/javascript/flavours/glitch/util/performance.js b/app/javascript/flavours/glitch/util/performance.js new file mode 100644 index 000000000..450a90626 --- /dev/null +++ b/app/javascript/flavours/glitch/util/performance.js @@ -0,0 +1,31 @@ +// +// Tools for performance debugging, only enabled in development mode. +// Open up Chrome Dev Tools, then Timeline, then User Timing to see output. +// Also see config/webpack/loaders/mark.js for the webpack loader marks. +// + +let marky; + +if (process.env.NODE_ENV === 'development') { + if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) { + // Increase Firefox's performance entry limit; otherwise it's capped to 150. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135 + performance.setResourceTimingBufferSize(Infinity); + } + marky = require('marky'); + // allows us to easily do e.g. ReactPerf.printWasted() while debugging + //window.ReactPerf = require('react-addons-perf'); + //window.ReactPerf.start(); +} + +export function start(name) { + if (process.env.NODE_ENV === 'development') { + marky.mark(name); + } +} + +export function stop(name) { + if (process.env.NODE_ENV === 'development') { + marky.stop(name); + } +} diff --git a/app/javascript/flavours/glitch/util/privacy_preference.js b/app/javascript/flavours/glitch/util/privacy_preference.js new file mode 100644 index 000000000..7781ca7fa --- /dev/null +++ b/app/javascript/flavours/glitch/util/privacy_preference.js @@ -0,0 +1,5 @@ +export const order = ['public', 'unlisted', 'private', 'direct']; + +export function privacyPreference (a, b) { + return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; +}; diff --git a/app/javascript/flavours/glitch/util/react_helpers.js b/app/javascript/flavours/glitch/util/react_helpers.js new file mode 100644 index 000000000..082a58e62 --- /dev/null +++ b/app/javascript/flavours/glitch/util/react_helpers.js @@ -0,0 +1,21 @@ +// This function binds the given `handlers` to the `target`. +export function assignHandlers (target, handlers) { + if (!target || !handlers) { + return; + } + + // We just bind each handler to the `target`. + const handle = target.handlers = {}; + Object.keys(handlers).forEach( + key => handle[key] = handlers[key].bind(target) + ); +} + +// This function only returns the component if the result of calling +// `test` with `data` is `true`. Useful with funciton binding. +export function conditionalRender (test, data, component) { + return test(data) ? component : null; +} + +// This object provides props to make the component not visible. +export const hiddenComponent = { style: { display: 'none' } }; diff --git a/app/javascript/flavours/glitch/util/react_router_helpers.js b/app/javascript/flavours/glitch/util/react_router_helpers.js new file mode 100644 index 000000000..e36c512f3 --- /dev/null +++ b/app/javascript/flavours/glitch/util/react_router_helpers.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Switch, Route } from 'react-router-dom'; + +import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading'; +import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; +import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container'; + +// Small wrapper to pass multiColumn to the route components +export class WrappedSwitch extends React.PureComponent { + + render () { + const { multiColumn, children } = this.props; + + return ( + <Switch> + {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} + </Switch> + ); + } + +} + +WrappedSwitch.propTypes = { + multiColumn: PropTypes.bool, + children: PropTypes.node, +}; + +// Small Wraper to extract the params from the route and pass +// them to the rendered component, together with the content to +// be rendered inside (the children) +export class WrappedRoute extends React.Component { + + static propTypes = { + component: PropTypes.func.isRequired, + content: PropTypes.node, + multiColumn: PropTypes.bool, + componentParams: PropTypes.object, + } + + static defaultProps = { + componentParams: {}, + }; + + renderComponent = ({ match }) => { + const { component, content, multiColumn, componentParams } = this.props; + + return ( + <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> + {Component => <Component params={match.params} multiColumn={multiColumn} {...componentParams}>{content}</Component>} + </BundleContainer> + ); + } + + renderLoading = () => { + return <ColumnLoading />; + } + + renderError = (props) => { + return <BundleColumnError {...props} />; + } + + render () { + const { component: Component, content, ...rest } = this.props; + + return <Route {...rest} render={this.renderComponent} />; + } + +} diff --git a/app/javascript/flavours/glitch/util/ready.js b/app/javascript/flavours/glitch/util/ready.js new file mode 100644 index 000000000..dd543910b --- /dev/null +++ b/app/javascript/flavours/glitch/util/ready.js @@ -0,0 +1,7 @@ +export default function ready(loaded) { + if (['interactive', 'complete'].includes(document.readyState)) { + loaded(); + } else { + document.addEventListener('DOMContentLoaded', loaded); + } +} diff --git a/app/javascript/flavours/glitch/util/reduced_motion.js b/app/javascript/flavours/glitch/util/reduced_motion.js new file mode 100644 index 000000000..95519042b --- /dev/null +++ b/app/javascript/flavours/glitch/util/reduced_motion.js @@ -0,0 +1,44 @@ +// Like react-motion's Motion, but reduces all animations to cross-fades +// for the benefit of users with motion sickness. +import React from 'react'; +import Motion from 'react-motion/lib/Motion'; +import PropTypes from 'prop-types'; + +const stylesToKeep = ['opacity', 'backgroundOpacity']; + +const extractValue = (value) => { + // This is either an object with a "val" property or it's a number + return (typeof value === 'object' && value && 'val' in value) ? value.val : value; +}; + +class ReducedMotion extends React.Component { + + static propTypes = { + defaultStyle: PropTypes.object, + style: PropTypes.object, + children: PropTypes.func, + } + + render() { + + const { style, defaultStyle, children } = this.props; + + Object.keys(style).forEach(key => { + if (stylesToKeep.includes(key)) { + return; + } + // If it's setting an x or height or scale or some other value, we need + // to preserve the end-state value without actually animating it + style[key] = defaultStyle[key] = extractValue(style[key]); + }); + + return ( + <Motion style={style} defaultStyle={defaultStyle}> + {children} + </Motion> + ); + } + +} + +export default ReducedMotion; diff --git a/app/javascript/flavours/glitch/util/redux_helpers.js b/app/javascript/flavours/glitch/util/redux_helpers.js new file mode 100644 index 000000000..8eb338da7 --- /dev/null +++ b/app/javascript/flavours/glitch/util/redux_helpers.js @@ -0,0 +1,8 @@ +import { injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; + +// Connects a component. +export function wrap (Component, mapStateToProps, mapDispatchToProps, options) { + const withIntl = typeof options === 'object' ? options.withIntl : !!options; + return (withIntl ? injectIntl : i => i)(connect(mapStateToProps, mapDispatchToProps)(Component)); +} diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js new file mode 100644 index 000000000..bbdbc865e --- /dev/null +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -0,0 +1,106 @@ +import EXIF from 'exif-js'; + +const MAX_IMAGE_PIXELS = 1638400; // 1280x1280px + +const getImageUrl = inputFile => new Promise((resolve, reject) => { + if (window.URL && URL.createObjectURL) { + try { + resolve(URL.createObjectURL(inputFile)); + } catch (error) { + reject(error); + } + return; + } + + const reader = new FileReader(); + reader.onerror = (...args) => reject(...args); + reader.onload = ({ target }) => resolve(target.result); + + reader.readAsDataURL(inputFile); +}); + +const loadImage = inputFile => new Promise((resolve, reject) => { + getImageUrl(inputFile).then(url => { + const img = new Image(); + + img.onerror = (...args) => reject(...args); + img.onload = () => resolve(img); + + img.src = url; + }).catch(reject); +}); + +const getOrientation = (img, type = 'image/png') => new Promise(resolve => { + if (!['image/jpeg', 'image/webp'].includes(type)) { + resolve(1); + return; + } + + EXIF.getData(img, () => { + const orientation = EXIF.getTag(img, 'Orientation'); + resolve(orientation); + }); +}); + +const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => { + const canvas = document.createElement('canvas'); + + if (4 < orientation && orientation < 9) { + canvas.width = height; + canvas.height = width; + } else { + canvas.width = width; + canvas.height = height; + } + + const context = canvas.getContext('2d'); + + switch (orientation) { + case 2: context.transform(-1, 0, 0, 1, width, 0); break; + case 3: context.transform(-1, 0, 0, -1, width, height); break; + case 4: context.transform(1, 0, 0, -1, 0, height); break; + case 5: context.transform(0, 1, 1, 0, 0, 0); break; + case 6: context.transform(0, 1, -1, 0, height, 0); break; + case 7: context.transform(0, -1, -1, 0, height, width); break; + case 8: context.transform(0, -1, 1, 0, 0, width); break; + } + + context.drawImage(img, 0, 0, width, height); + + canvas.toBlob(resolve, type); +}); + +const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => { + const { width, height } = img; + + const newWidth = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height))); + const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width))); + + getOrientation(img, type) + .then(orientation => processImage(img, { + width: newWidth, + height: newHeight, + orientation, + type, + })) + .then(resolve) + .catch(reject); +}); + +export default inputFile => new Promise((resolve, reject) => { + if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { + resolve(inputFile); + return; + } + + loadImage(inputFile).then(img => { + if (img.width * img.height < MAX_IMAGE_PIXELS) { + resolve(inputFile); + return; + } + + resizeImage(img, inputFile.type) + .then(resolve) + .catch(() => resolve(inputFile)); + }).catch(reject); +}); diff --git a/app/javascript/flavours/glitch/util/rtl.js b/app/javascript/flavours/glitch/util/rtl.js new file mode 100644 index 000000000..00870a15d --- /dev/null +++ b/app/javascript/flavours/glitch/util/rtl.js @@ -0,0 +1,31 @@ +// U+0590 to U+05FF - Hebrew +// U+0600 to U+06FF - Arabic +// U+0700 to U+074F - Syriac +// U+0750 to U+077F - Arabic Supplement +// U+0780 to U+07BF - Thaana +// U+07C0 to U+07FF - N'Ko +// U+0800 to U+083F - Samaritan +// U+08A0 to U+08FF - Arabic Extended-A +// U+FB1D to U+FB4F - Hebrew presentation forms +// U+FB50 to U+FDFF - Arabic presentation forms A +// U+FE70 to U+FEFF - Arabic presentation forms B + +const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; + +export function isRtl(text) { + if (text.length === 0) { + return false; + } + + text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, ''); + text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, ''); + text = text.replace(/\s+/g, ''); + + const matches = text.match(rtlChars); + + if (!matches) { + return false; + } + + return matches.length / text.length > 0.3; +}; diff --git a/app/javascript/flavours/glitch/util/schedule_idle_task.js b/app/javascript/flavours/glitch/util/schedule_idle_task.js new file mode 100644 index 000000000..b04d4a8ee --- /dev/null +++ b/app/javascript/flavours/glitch/util/schedule_idle_task.js @@ -0,0 +1,29 @@ +// Wrapper to call requestIdleCallback() to schedule low-priority work. +// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API +// for a good breakdown of the concepts behind this. + +import Queue from 'tiny-queue'; + +const taskQueue = new Queue(); +let runningRequestIdleCallback = false; + +function runTasks(deadline) { + while (taskQueue.length && deadline.timeRemaining() > 0) { + taskQueue.shift()(); + } + if (taskQueue.length) { + requestIdleCallback(runTasks); + } else { + runningRequestIdleCallback = false; + } +} + +function scheduleIdleTask(task) { + taskQueue.push(task); + if (!runningRequestIdleCallback) { + runningRequestIdleCallback = true; + requestIdleCallback(runTasks); + } +} + +export default scheduleIdleTask; diff --git a/app/javascript/flavours/glitch/util/scroll.js b/app/javascript/flavours/glitch/util/scroll.js new file mode 100644 index 000000000..2af07e0fb --- /dev/null +++ b/app/javascript/flavours/glitch/util/scroll.js @@ -0,0 +1,30 @@ +const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b; + +const scroll = (node, key, target) => { + const startTime = Date.now(); + const offset = node[key]; + const gap = target - offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node[key] = easingOutQuint(0, elapsed, offset, gap, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position); +export const scrollTop = (node) => scroll(node, 'scrollTop', 0); diff --git a/app/javascript/flavours/glitch/util/settings.js b/app/javascript/flavours/glitch/util/settings.js new file mode 100644 index 000000000..7643a508e --- /dev/null +++ b/app/javascript/flavours/glitch/util/settings.js @@ -0,0 +1,47 @@ +export default class Settings { + + constructor(keyBase = null) { + this.keyBase = keyBase; + } + + generateKey(id) { + return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id; + } + + set(id, data) { + const key = this.generateKey(id); + try { + const encodedData = JSON.stringify(data); + localStorage.setItem(key, encodedData); + return data; + } catch (e) { + return null; + } + } + + get(id) { + const key = this.generateKey(id); + try { + const rawData = localStorage.getItem(key); + return JSON.parse(rawData); + } catch (e) { + return null; + } + } + + remove(id) { + const data = this.get(id); + if (data) { + const key = this.generateKey(id); + try { + localStorage.removeItem(key); + } catch (e) { + } + } + return data; + } + +} + +export const pushNotificationsSetting = new Settings('mastodon_push_notification_data'); +export const tagHistory = new Settings('mastodon_tag_history'); diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js new file mode 100644 index 000000000..306a068b7 --- /dev/null +++ b/app/javascript/flavours/glitch/util/stream.js @@ -0,0 +1,86 @@ +import WebSocketClient from 'websocket.js'; + +const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); + +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); + + let polling = null; + + const setupPolling = () => { + pollingRefresh(dispatch, () => { + polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000)); + }); + }; + + const clearPolling = () => { + if (polling) { + clearTimeout(polling); + polling = null; + } + }; + + const subscription = getStream(streamingAPIBaseURL, accessToken, path, { + connected () { + if (pollingRefresh) { + clearPolling(); + } + + onConnect(); + }, + + disconnected () { + if (pollingRefresh) { + polling = setTimeout(() => setupPolling(), randomIntUpTo(40000)); + } + + onDisconnect(); + }, + + received (data) { + onReceive(data); + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + + onConnect(); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + + clearPolling(); + }; + + return disconnect; + }; +} + + +export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { + const params = [ `stream=${stream}` ]; + + if (accessToken !== null) { + params.push(`access_token=${accessToken}`); + } + + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + ws.onreconnect = reconnected; + + return ws; +}; diff --git a/app/javascript/flavours/glitch/util/url_regex.js b/app/javascript/flavours/glitch/util/url_regex.js new file mode 100644 index 000000000..faed22169 --- /dev/null +++ b/app/javascript/flavours/glitch/util/url_regex.js @@ -0,0 +1,196 @@ +const regexen = {}; + +const regexSupplant = function(regex, flags) { + flags = flags || ''; + if (typeof regex !== 'string') { + if (regex.global && flags.indexOf('g') < 0) { + flags += 'g'; + } + if (regex.ignoreCase && flags.indexOf('i') < 0) { + flags += 'i'; + } + if (regex.multiline && flags.indexOf('m') < 0) { + flags += 'm'; + } + + regex = regex.source; + } + return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) { + var newRegex = regexen[name] || ''; + if (typeof newRegex !== 'string') { + newRegex = newRegex.source; + } + return newRegex; + }), flags); +}; + +const stringSupplant = function(str, values) { + return str.replace(/#\{(\w+)\}/g, function(match, name) { + return values[name] || ''; + }); +}; + +export const urlRegex = (function() { + regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/; + regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/; + regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/; + regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/); + regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen); + regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/); + regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/); + regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/); + regexen.validGTLD = regexSupplant(RegExp( + '(?:(?:' + + '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' + + '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' + + 'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' + + 'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' + + 'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' + + 'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' + + 'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' + + 'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' + + 'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' + + 'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' + + 'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' + + 'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' + + 'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' + + 'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' + + 'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' + + 'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' + + 'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' + + 'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' + + 'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' + + 'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' + + 'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' + + 'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' + + 'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' + + 'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' + + 'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' + + 'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' + + 'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' + + 'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' + + 'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' + + 'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' + + 'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' + + 'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' + + 'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' + + 'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' + + 'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' + + 'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' + + 'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' + + 'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' + + 'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' + + 'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' + + 'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' + + 'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' + + 'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' + + 'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' + + 'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' + + 'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' + + 'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' + + 'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' + + 'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' + + 'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' + + 'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' + + 'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' + + 'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' + + 'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' + + 'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' + + 'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' + + 'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' + + 'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' + + 'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' + + 'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' + + 'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' + + 'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' + + 'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' + + 'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' + + 'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' + + 'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' + + 'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' + + 'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' + + 'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' + + 'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' + + 'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' + + 'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' + + 'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' + + 'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' + + 'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' + + 'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' + + 'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' + + 'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' + + 'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' + + 'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' + + 'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' + + 'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' + + 'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' + + 'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' + + 'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' + + 'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' + + 'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' + + 'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' + + ')(?=[^0-9a-zA-Z@]|$))')); + regexen.validCCTLD = regexSupplant(RegExp( + '(?:(?:' + + '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' + + 'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' + + 'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' + + 'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' + + 'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' + + 're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' + + 'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' + + 'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' + + 'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' + + 'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' + + 'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' + + ')(?=[^0-9a-zA-Z@]|$))')); + regexen.validPunycode = /(?:xn--[0-9a-z]+)/; + regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/; + regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/); + regexen.validPortNumber = /[0-9]+/; + regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/; + regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i); + // Allow URL paths to contain up to two nested levels of balanced parens + // 1. Used in Wikipedia URLs like /Primer_(film) + // 2. Used in IIS sessions like /S(dfd346)/ + // 3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/ + regexen.validUrlBalancedParens = regexSupplant( + '\\(' + + '(?:' + + '#{validGeneralUrlPathChars}+' + + '|' + + // allow one nested level of balanced parentheses + '(?:' + + '#{validGeneralUrlPathChars}*' + + '\\(' + + '#{validGeneralUrlPathChars}+' + + '\\)' + + '#{validGeneralUrlPathChars}*' + + ')' + + ')' + + '\\)' + , 'i'); + // Valid end-of-path chracters (so /foo. does not gobble the period). + // 1. Allow =&# for empty URL parameters and other URL-join artifacts + regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i); + // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/ + regexen.validUrlPath = regexSupplant('(?:' + + '(?:' + + '#{validGeneralUrlPathChars}*' + + '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' + + '#{validUrlPathEndingChars}'+ + ')|(?:@#{validGeneralUrlPathChars}+\/)'+ + ')', 'i'); + regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i; + regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i; + regexen.validUrl = regexSupplant( + '(' + // $1 URL + '(https?:\\/\\/)' + // $2 Protocol + '(#{validDomain})' + // $3 Domain(s) + '(?::(#{validPortNumber}))?' + // $4 Port number (optional) + '(\\/#{validUrlPath}*)?' + // $5 URL Path + '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $6 Query String + ')' + , 'gi'); + return regexen.validUrl; +}()); diff --git a/app/javascript/flavours/glitch/util/uuid.js b/app/javascript/flavours/glitch/util/uuid.js new file mode 100644 index 000000000..be1899305 --- /dev/null +++ b/app/javascript/flavours/glitch/util/uuid.js @@ -0,0 +1,3 @@ +export default function uuid(a) { + return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid); +}; diff --git a/app/javascript/flavours/vanilla/names.yml b/app/javascript/flavours/vanilla/names.yml new file mode 100644 index 000000000..1b3dab9c9 --- /dev/null +++ b/app/javascript/flavours/vanilla/names.yml @@ -0,0 +1,16 @@ +en: + flavours: + vanilla: + description: The theme used by vanilla Mastodon instances. This theme might not support all of the features of GlitchSoc. + name: Vanilla Mastodon + skins: + vanilla: + default: Default +pl: + flavours: + vanilla: + description: Motyw używany przez instancje czystego Mastodona. Może nie obsługiwać wszystkich funkcji GlitchSoc. + name: Mastodon Vanilla + skins: + vanilla: + default: Domyślny diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml new file mode 100644 index 000000000..42e26daea --- /dev/null +++ b/app/javascript/flavours/vanilla/theme.yml @@ -0,0 +1,42 @@ +# (REQUIRED) The location of the pack files inside `pack_directory`. +pack: + about: about.js + admin: public.js + auth: + common: + filename: common.js + stylesheet: true + embed: public.js + error: error.js + home: + filename: application.js + preload: + - features/getting_started + - features/compose + - features/home_timeline + - features/notifications + mailer: + modal: + public: public.js + settings: public.js + share: share.js + +# (OPTIONAL) The directory which contains localization files for +# the flavour, relative to this directory. +locales: ../../mastodon/locales + +# (OPTIONAL) A file to use as the preview screenshot for the flavour, +# or an array thereof. These are the full path from `app/javascript/`. +screenshot: images/screenshot.jpg + +# (OPTIONAL) The directory which contains the pack files. +# Defaults to this directory (`app/javascript/flavour/[flavour]`), +# but in the case of the vanilla Mastodon flavour the pack files are +# somewhere else. +pack_directory: app/javascript/packs + +# (OPTIONAL) By default the theme will fallback to the default flavour +# if a particular pack is not provided. You can specify different +# fallbacks here, or disable fallback behaviours altogether by +# specifying a `null` value. +fallback: diff --git a/app/javascript/fonts/premillenium/MSSansSerif.ttf b/app/javascript/fonts/premillenium/MSSansSerif.ttf new file mode 100644 index 000000000..3afd76ff2 --- /dev/null +++ b/app/javascript/fonts/premillenium/MSSansSerif.ttf Binary files differdiff --git a/app/javascript/images/clippy_frame.png b/app/javascript/images/clippy_frame.png new file mode 100644 index 000000000..7f2cd6a59 --- /dev/null +++ b/app/javascript/images/clippy_frame.png Binary files differdiff --git a/app/javascript/images/clippy_wave.gif b/app/javascript/images/clippy_wave.gif new file mode 100644 index 000000000..4d2e38a3d --- /dev/null +++ b/app/javascript/images/clippy_wave.gif Binary files differdiff --git a/app/javascript/images/icon_about.png b/app/javascript/images/icon_about.png new file mode 100644 index 000000000..08b76dcd9 --- /dev/null +++ b/app/javascript/images/icon_about.png Binary files differdiff --git a/app/javascript/images/icon_blocks.png b/app/javascript/images/icon_blocks.png new file mode 100644 index 000000000..8b1490875 --- /dev/null +++ b/app/javascript/images/icon_blocks.png Binary files differdiff --git a/app/javascript/images/icon_follow_requests.png b/app/javascript/images/icon_follow_requests.png new file mode 100644 index 000000000..4123e2a69 --- /dev/null +++ b/app/javascript/images/icon_follow_requests.png Binary files differdiff --git a/app/javascript/images/icon_home.png b/app/javascript/images/icon_home.png new file mode 100644 index 000000000..66ce779c0 --- /dev/null +++ b/app/javascript/images/icon_home.png Binary files differdiff --git a/app/javascript/images/icon_keyboard_shortcuts.png b/app/javascript/images/icon_keyboard_shortcuts.png new file mode 100644 index 000000000..d66f3939e --- /dev/null +++ b/app/javascript/images/icon_keyboard_shortcuts.png Binary files differdiff --git a/app/javascript/images/icon_likes.png b/app/javascript/images/icon_likes.png new file mode 100644 index 000000000..17d7a9c59 --- /dev/null +++ b/app/javascript/images/icon_likes.png Binary files differdiff --git a/app/javascript/images/icon_lists.png b/app/javascript/images/icon_lists.png new file mode 100644 index 000000000..3828946e8 --- /dev/null +++ b/app/javascript/images/icon_lists.png Binary files differdiff --git a/app/javascript/images/icon_local.png b/app/javascript/images/icon_local.png new file mode 100644 index 000000000..5f82df395 --- /dev/null +++ b/app/javascript/images/icon_local.png Binary files differdiff --git a/app/javascript/images/icon_logout.png b/app/javascript/images/icon_logout.png new file mode 100644 index 000000000..7ff806f58 --- /dev/null +++ b/app/javascript/images/icon_logout.png Binary files differdiff --git a/app/javascript/images/icon_mutes.png b/app/javascript/images/icon_mutes.png new file mode 100644 index 000000000..c2225e966 --- /dev/null +++ b/app/javascript/images/icon_mutes.png Binary files differdiff --git a/app/javascript/images/icon_pin.png b/app/javascript/images/icon_pin.png new file mode 100644 index 000000000..2329d8c54 --- /dev/null +++ b/app/javascript/images/icon_pin.png Binary files differdiff --git a/app/javascript/images/icon_public.png b/app/javascript/images/icon_public.png new file mode 100644 index 000000000..3c09460db --- /dev/null +++ b/app/javascript/images/icon_public.png Binary files differdiff --git a/app/javascript/images/icon_settings.png b/app/javascript/images/icon_settings.png new file mode 100644 index 000000000..07f5c4519 --- /dev/null +++ b/app/javascript/images/icon_settings.png Binary files differdiff --git a/app/javascript/images/screenshot.jpg b/app/javascript/images/screenshot.jpg new file mode 100644 index 000000000..45b270fbb --- /dev/null +++ b/app/javascript/images/screenshot.jpg Binary files differdiff --git a/app/javascript/images/start.png b/app/javascript/images/start.png new file mode 100644 index 000000000..7843455b6 --- /dev/null +++ b/app/javascript/images/start.png Binary files differdiff --git a/app/javascript/locales/index.js b/app/javascript/locales/index.js new file mode 100644 index 000000000..421cb7fab --- /dev/null +++ b/app/javascript/locales/index.js @@ -0,0 +1,9 @@ +let theLocale; + +export function setLocale(locale) { + theLocale = locale; +} + +export function getLocale() { + return theLocale; +} diff --git a/app/javascript/mastodon/locales/locale-data/README.md b/app/javascript/locales/locale-data/README.md index 83368fae7..83368fae7 100644 --- a/app/javascript/mastodon/locales/locale-data/README.md +++ b/app/javascript/locales/locale-data/README.md diff --git a/app/javascript/mastodon/locales/locale-data/oc.js b/app/javascript/locales/locale-data/oc.js index d4adc42eb..d4adc42eb 100644 --- a/app/javascript/mastodon/locales/locale-data/oc.js +++ b/app/javascript/locales/locale-data/oc.js diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index cf82ef5a8..7e8b38580 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -20,6 +20,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { length } from 'stringz'; import { countableText } from '../util/counter'; import Icon from 'mastodon/components/icon'; +import { maxChars } from '../../../initial_state'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; @@ -87,7 +88,7 @@ class ComposeForm extends ImmutablePureComponent { const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; const fulltext = [this.props.spoilerText, countableText(this.props.text)].join(''); - if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { return; } @@ -167,7 +168,7 @@ class ComposeForm extends ImmutablePureComponent { const { intl, onPaste, showSearch, anyMedia } = this.props; const disabled = this.props.isSubmitting; const text = [this.props.spoilerText, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); + const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { @@ -231,7 +232,7 @@ class ComposeForm extends ImmutablePureComponent { <PrivacyDropdownContainer /> <SpoilerButtonContainer /> </div> - <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> + <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div> </div> <div className='compose-form__publish'> diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 74bcfee58..d74f5ceb1 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -12,6 +12,7 @@ export const boostModal = getMeta('boost_modal'); export const deleteModal = getMeta('delete_modal'); export const me = getMeta('me'); export const searchEnabled = getMeta('search_enabled'); +export const maxChars = (initialState && initialState.max_toot_chars) || 500; export const invitesEnabled = getMeta('invites_enabled'); export const repository = getMeta('repository'); export const source_url = getMeta('source_url'); diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 11b6dd854..8e7ed4210 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -2170,6 +2170,19 @@ { "descriptors": [ { + "defaultMessage": "Favourite", + "id": "status.favourite" + }, + { + "defaultMessage": "You can press {combo} to skip this next time", + "id": "favourite_modal.combo" + } + ], + "path": "app/javascript/mastodon/features/ui/components/favourite_modal.json" + }, + { + "descriptors": [ + { "defaultMessage": "Network error", "id": "bundle_column_error.title" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 272f22088..b1eb814fc 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -63,6 +63,10 @@ "column_header.pin": "Pin", "column_header.show_settings": "Show settings", "column_header.unpin": "Unpin", + "column.heading": "Misc", + "column.subheading": "Miscellaneous options", + "column_subheading.lists": "Lists", + "column_subheading.navigation": "Navigation", "column_subheading.settings": "Settings", "community.column_settings.media_only": "Media Only", "compose_form.direct_message_warning": "This toot will only be sent to the mentioned users.", @@ -239,6 +243,7 @@ "navigation_bar.info": "About this server", "navigation_bar.keyboard_shortcuts": "Hotkeys", "navigation_bar.lists": "Lists", + "navigation_bar.misc": "Misc", "navigation_bar.logout": "Logout", "navigation_bar.mutes": "Muted users", "navigation_bar.personal": "Personal", diff --git a/app/javascript/mastodon/locales/index.js b/app/javascript/mastodon/locales/index.js index 421cb7fab..7e7297561 100644 --- a/app/javascript/mastodon/locales/index.js +++ b/app/javascript/mastodon/locales/index.js @@ -1,9 +1 @@ -let theLocale; - -export function setLocale(locale) { - theLocale = locale; -} - -export function getLocale() { - return theLocale; -} +export * from 'locales'; diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index c9e7a7b90..509d0c2e8 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -63,6 +63,10 @@ "column_header.pin": "ピン留めする", "column_header.show_settings": "設定を表示", "column_header.unpin": "ピン留めを外す", + "column.heading": "その他", + "column.subheading": "その他のオプション", + "column_subheading.lists": "リスト", + "column_subheading.navigation": "ナビゲーション", "column_subheading.settings": "設定", "community.column_settings.media_only": "メディアのみ表示", "compose_form.direct_message_warning": "このトゥートはメンションされた人にのみ送信されます。", @@ -245,6 +249,7 @@ "navigation_bar.pins": "固定したトゥート", "navigation_bar.preferences": "ユーザー設定", "navigation_bar.public_timeline": "連合タイムライン", + "navigation_bar.misc": "その他", "navigation_bar.security": "セキュリティ", "notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました", "notification.follow": "{name}さんにフォローされました", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index b25a2c5f1..fce97f00e 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -63,6 +63,10 @@ "column_header.pin": "Przypnij", "column_header.show_settings": "Pokaż ustawienia", "column_header.unpin": "Cofnij przypięcie", + "column.heading": "Różne", + "column.subheading": "Różne opcje", + "column_subheading.lists": "Listy", + "column_subheading.navigation": "Nawigacja", "column_subheading.settings": "Ustawienia", "community.column_settings.media_only": "Tylko zawartość multimedialna", "compose_form.direct_message_warning": "Ten wpis będzie widoczny tylko dla wszystkich wspomnianych użytkowników.", @@ -238,6 +242,7 @@ "navigation_bar.keyboard_shortcuts": "Skróty klawiszowe", "navigation_bar.lists": "Listy", "navigation_bar.logout": "Wyloguj", + "navigation_bar.misc": "Różne", "navigation_bar.mutes": "Wyciszeni użytkownicy", "navigation_bar.personal": "Osobiste", "navigation_bar.pins": "Przypięte wpisy", diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js new file mode 100644 index 000000000..5d42509c5 --- /dev/null +++ b/app/javascript/packs/common.js @@ -0,0 +1 @@ +import 'styles/application.scss'; diff --git a/app/javascript/packs/mailer.js b/app/javascript/packs/mailer.js deleted file mode 100644 index 732fc1698..000000000 --- a/app/javascript/packs/mailer.js +++ /dev/null @@ -1 +0,0 @@ -require('../styles/mailer.scss'); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 93379cdb3..69e6ba0ec 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,26 +1,9 @@ -import escapeTextContentForBrowser from 'escape-html'; import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; start(); -window.addEventListener('message', e => { - const data = e.data || {}; - - if (!window.parent || data.type !== 'setHeight') { - return; - } - - ready(() => { - window.parent.postMessage({ - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0].scrollHeight, - }, '*'); - }); -}); - function main() { const IntlMessageFormat = require('intl-messageformat').default; const { timeAgoString } = require('../mastodon/components/relative_timestamp'); @@ -117,126 +100,6 @@ function main() { scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0); } }); - - delegate(document, '.webapp-btn', 'click', ({ target, button }) => { - if (button !== 0) { - return true; - } - window.location.href = target.href; - return false; - }); - - delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => { - const contentEl = target.parentNode.parentNode.querySelector('.e-content'); - - if (contentEl.style.display === 'block') { - contentEl.style.display = 'none'; - target.parentNode.style.marginBottom = 0; - } else { - contentEl.style.display = 'block'; - target.parentNode.style.marginBottom = null; - } - - return false; - }); - - delegate(document, '.modal-button', 'click', e => { - e.preventDefault(); - - let href; - - if (e.target.nodeName !== 'A') { - href = e.target.parentNode.href; - } else { - href = e.target.href; - } - - window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); - }); - - delegate(document, '#account_display_name', 'input', ({ target }) => { - const name = document.querySelector('.card .display-name strong'); - if (name) { - if (target.value) { - name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); - } else { - name.textContent = document.querySelector('#default_account_display_name').textContent; - } - } - }); - - delegate(document, '#account_avatar', 'change', ({ target }) => { - const avatar = document.querySelector('.card .avatar img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; - - avatar.src = url; - }); - - const getProfileAvatarAnimationHandler = (swapTo) => { - //animate avatar gifs on the profile page when moused over - return ({ target }) => { - const swapSrc = target.getAttribute(swapTo); - //only change the img source if autoplay is off and the image src is actually different - if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) { - target.src = swapSrc; - } - }; - }; - - delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); - - delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); - - delegate(document, '#account_header', 'change', ({ target }) => { - const header = document.querySelector('.card .card__img img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; - - header.src = url; - }); - - delegate(document, '#account_locked', 'change', ({ target }) => { - const lock = document.querySelector('.card .display-name i'); - - if (target.checked) { - lock.style.display = 'inline'; - } else { - lock.style.display = 'none'; - } - }); - - delegate(document, '.input-copy input', 'click', ({ target }) => { - target.focus(); - target.select(); - target.setSelectionRange(0, target.value.length); - }); - - delegate(document, '.input-copy button', 'click', ({ target }) => { - const input = target.parentNode.querySelector('.input-copy__wrapper input'); - - const oldReadOnly = input.readonly; - - input.readonly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - target.parentNode.classList.add('copied'); - - setTimeout(() => { - target.parentNode.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } - - input.readonly = oldReadOnly; - }); } loadPolyfills().then(main).catch(error => { diff --git a/app/javascript/skins/glitch/contrast/common.scss b/app/javascript/skins/glitch/contrast/common.scss new file mode 100644 index 000000000..90919d6d4 --- /dev/null +++ b/app/javascript/skins/glitch/contrast/common.scss @@ -0,0 +1 @@ +@import 'flavours/glitch/styles/contrast'; diff --git a/app/javascript/skins/glitch/contrast/names.yml b/app/javascript/skins/glitch/contrast/names.yml new file mode 100644 index 000000000..f60c4acd0 --- /dev/null +++ b/app/javascript/skins/glitch/contrast/names.yml @@ -0,0 +1,4 @@ +en: + skins: + glitch: + contrast: High contrast diff --git a/app/javascript/skins/glitch/mastodon-light/common.scss b/app/javascript/skins/glitch/mastodon-light/common.scss new file mode 100644 index 000000000..c37f407b3 --- /dev/null +++ b/app/javascript/skins/glitch/mastodon-light/common.scss @@ -0,0 +1 @@ +@import 'flavours/glitch/styles/mastodon-light'; diff --git a/app/javascript/skins/glitch/mastodon-light/names.yml b/app/javascript/skins/glitch/mastodon-light/names.yml new file mode 100644 index 000000000..f15424f2b --- /dev/null +++ b/app/javascript/skins/glitch/mastodon-light/names.yml @@ -0,0 +1,5 @@ +en: + skins: + glitch: + mastodon-light: Mastodon (light) + diff --git a/app/javascript/skins/vanilla/contrast/common.scss b/app/javascript/skins/vanilla/contrast/common.scss new file mode 100644 index 000000000..5f752b6d4 --- /dev/null +++ b/app/javascript/skins/vanilla/contrast/common.scss @@ -0,0 +1 @@ +@import 'styles/contrast'; diff --git a/app/javascript/skins/vanilla/contrast/names.yml b/app/javascript/skins/vanilla/contrast/names.yml new file mode 100644 index 000000000..d9b930e86 --- /dev/null +++ b/app/javascript/skins/vanilla/contrast/names.yml @@ -0,0 +1,4 @@ +en: + skins: + vanilla: + contrast: High contrast diff --git a/app/javascript/skins/vanilla/mastodon-light/common.scss b/app/javascript/skins/vanilla/mastodon-light/common.scss new file mode 100644 index 000000000..e1a3ea2c6 --- /dev/null +++ b/app/javascript/skins/vanilla/mastodon-light/common.scss @@ -0,0 +1 @@ +@import 'styles/mastodon-light'; diff --git a/app/javascript/skins/vanilla/mastodon-light/names.yml b/app/javascript/skins/vanilla/mastodon-light/names.yml new file mode 100644 index 000000000..bb14ad2e0 --- /dev/null +++ b/app/javascript/skins/vanilla/mastodon-light/names.yml @@ -0,0 +1,4 @@ +en: + skins: + vanilla: + mastodon-light: Mastodon (light) diff --git a/app/javascript/skins/vanilla/win95/common.scss b/app/javascript/skins/vanilla/win95/common.scss new file mode 100644 index 000000000..298f6ee9d --- /dev/null +++ b/app/javascript/skins/vanilla/win95/common.scss @@ -0,0 +1 @@ +@import 'styles/win95'; diff --git a/app/javascript/skins/vanilla/win95/names.yml b/app/javascript/skins/vanilla/win95/names.yml new file mode 100644 index 000000000..2083084a2 --- /dev/null +++ b/app/javascript/skins/vanilla/win95/names.yml @@ -0,0 +1,4 @@ +en: + skins: + vanilla: + win95: Masto95 diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss index 8079dc6fc..80c2329b0 100644 --- a/app/javascript/styles/fonts/montserrat.scss +++ b/app/javascript/styles/fonts/montserrat.scss @@ -1,9 +1,9 @@ @font-face { font-family: 'mastodon-font-display'; src: local('Montserrat'), - url('../fonts/montserrat/Montserrat-Regular.woff2') format('woff2'), - url('../fonts/montserrat/Montserrat-Regular.woff') format('woff'), - url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype'); + url('~fonts/montserrat/Montserrat-Regular.woff2') format('woff2'), + url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'), + url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype'); font-weight: 400; font-style: normal; } @@ -11,7 +11,7 @@ @font-face { font-family: 'mastodon-font-display'; src: local('Montserrat Medium'), - url('../fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); + url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); font-weight: 500; font-style: normal; } diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss index 2a1f74e16..c793aa6ed 100644 --- a/app/javascript/styles/fonts/roboto-mono.scss +++ b/app/javascript/styles/fonts/roboto-mono.scss @@ -1,10 +1,10 @@ @font-face { font-family: 'mastodon-font-monospace'; src: local('Roboto Mono'), - url('../fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'), - url('../fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'), - url('../fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'), - url('../fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg'); + url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'), + url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'), + url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'), + url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg'); font-weight: 400; font-style: normal; } diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss index f9c7c50fe..b75fdb927 100644 --- a/app/javascript/styles/fonts/roboto.scss +++ b/app/javascript/styles/fonts/roboto.scss @@ -1,10 +1,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto Italic'), - url('../fonts/roboto/roboto-italic-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-italic-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-italic-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg'); + url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg'); font-weight: normal; font-style: italic; } @@ -12,10 +12,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto Bold'), - url('../fonts/roboto/roboto-bold-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-bold-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-bold-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg'); + url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg'); font-weight: bold; font-style: normal; } @@ -23,10 +23,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto Medium'), - url('../fonts/roboto/roboto-medium-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-medium-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-medium-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg'); + url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg'); font-weight: 500; font-style: normal; } @@ -34,10 +34,10 @@ @font-face { font-family: 'mastodon-font-sans-serif'; src: local('Roboto'), - url('../fonts/roboto/roboto-regular-webfont.woff2') format('woff2'), - url('../fonts/roboto/roboto-regular-webfont.woff') format('woff'), - url('../fonts/roboto/roboto-regular-webfont.ttf') format('truetype'), - url('../fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg'); + url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'), + url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'), + url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'), + url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg'); font-weight: normal; font-style: normal; } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index dd3b47a8f..74f91599a 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -305,6 +305,22 @@ $content-width: 840px; } } +.flavour-screen { + display: block; + margin: 10px auto; + max-width: 100%; +} + +.flavour-description { + display: block; + font-size: 16px; + margin: 10px 0; + + & > p { + margin: 10px 0; + } +} + .report-accounts { display: flex; flex-wrap: wrap; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e8c5f70f5..e8fd8a240 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1495,7 +1495,7 @@ a.account__display-name { .image-loader__preview-canvas { max-width: $media-modal-media-max-width; max-height: $media-modal-media-max-height; - background: url('../images/void.png') repeat; + background: url('~images/void.png') repeat; object-fit: contain; } @@ -2623,7 +2623,7 @@ a.status-card.compact:hover { } &__figure { - background: url('../images/elephant_ui_working.svg') no-repeat center 0; + background: url('~images/elephant_ui_working.svg') no-repeat center 0; width: 100%; height: 160px; background-size: contain; @@ -2637,7 +2637,7 @@ a.status-card.compact:hover { padding-top: 20px + 48px; .regeneration-indicator__figure { - background-image: url('../images/elephant_ui_disappointed.svg'); + background-image: url('~images/elephant_ui_disappointed.svg'); } } @@ -5270,7 +5270,7 @@ noscript { width: 100px; height: 100px; transform: translate(-50%, -50%); - background: url('../images/reticle.png') no-repeat 0 0; + background: url('~images/reticle.png') no-repeat 0 0; border-radius: 50%; box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35); } diff --git a/app/javascript/styles/mastodon/modal.scss b/app/javascript/styles/mastodon/modal.scss index 962ed1ef5..10de454c6 100644 --- a/app/javascript/styles/mastodon/modal.scss +++ b/app/javascript/styles/mastodon/modal.scss @@ -15,7 +15,7 @@ > * { flex: 1; max-height: 235px; - background: url('../images/elephant_ui_plane.svg') no-repeat left bottom / contain; + background: url('~images/elephant_ui_plane.svg') no-repeat left bottom / contain; } } diff --git a/app/javascript/styles/win95.scss b/app/javascript/styles/win95.scss new file mode 100644 index 000000000..fdf2e59b6 --- /dev/null +++ b/app/javascript/styles/win95.scss @@ -0,0 +1,1763 @@ +// win95 theme from cybrespace. + +// Modified by kibi! to use webpack package syntax for urls (eg, +// `url(~images/…)`) for easy importing into skins. + +$win95-bg: #bfbfbf; +$win95-dark-grey: #404040; +$win95-mid-grey: #808080; +$win95-window-header: #00007f; +$win95-tooltip-yellow: #ffffcc; +$win95-blue: blue; + +$ui-base-lighter-color: $win95-dark-grey; +$ui-highlight-color: $win95-window-header; + +@mixin win95-border-outset() { + border-left: 2px solid #efefef; + border-top: 2px solid #efefef; + border-right: 2px solid #404040; + border-bottom: 2px solid #404040; + border-radius:0px; +} + +@mixin win95-outset() { + box-shadow: inset -1px -1px 0px #000000, + inset 1px 1px 0px #ffffff, + inset -2px -2px 0px #808080, + inset 2px 2px 0px #dfdfdf; + border-radius:0px; +} + +@mixin win95-border-inset() { + border-left: 2px solid #404040; + border-top: 2px solid #404040; + border-right: 2px solid #efefef; + border-bottom: 2px solid #efefef; + border-radius:0px; +} + +@mixin win95-border-slight-inset() { + border-left: 1px solid #404040; + border-top: 1px solid #404040; + border-right: 1px solid #efefef; + border-bottom: 1px solid #efefef; + border-radius:0px; +} + +@mixin win95-inset() { + box-shadow: inset 1px 1px 0px #000000, + inset -1px -1px 0px #ffffff, + inset 2px 2px 0px #808080, + inset -2px -2px 0px #dfdfdf; + border-width:0px; + border-radius:0px; +} + +@mixin win95-tab() { + box-shadow: inset -1px 0px 0px #000000, + inset 1px 0px 0px #ffffff, + inset 0px 1px 0px #ffffff, + inset 0px 2px 0px #dfdfdf, + inset -2px 0px 0px #808080, + inset 2px 0px 0px #dfdfdf; + border-radius:0px; + border-top-left-radius: 1px; + border-top-right-radius: 1px; +} + +@mixin win95-reset() { + box-shadow: unset; +} + +@font-face { + font-family:"premillenium"; + src: url('~fonts/premillenium/MSSansSerif.ttf') format('truetype'); +} + +@import 'application'; + +/* borrowed from cybrespace style: wider columns and full column width images */ + +@media screen and (min-width: 1300px) { + .column { + flex-grow: 1 !important; + max-width: 400px; + } + + .drawer { + width: 17%; + max-width: 400px; + min-width: 330px; + } +} + +.media-gallery, +.video-player { + max-height:30vh; + height:30vh !important; + position:relative; + margin-top:20px; + margin-left:-68px; + width: calc(100% + 80px) !important; + max-width: calc(100% + 80px); +} + +.detailed-status .media-gallery, +.detailed-status .video-player { + margin-left:-5px; + width: calc(100% + 9px); + max-width: calc(100% + 9px); +} + +.video-player video { + transform: unset; + top: unset; +} + +.detailed-status .media-spoiler, +.status .media-spoiler { + height: 100%!important; + vertical-align: middle; +} + +/* main win95 style */ + +body { + font-size:13px; + font-family: "MS Sans Serif", "premillenium", sans-serif; + color:black; +} + +.ui, +.ui .columns-area, +body.admin { + background: #008080; +} + +.loading-bar { + height:5px; + background-color: #000080; +} + +.tabs-bar { + background: $win95-bg; + @include win95-outset(); + height: 30px; +} + +.tabs-bar__link { + color:black; + border:2px outset $win95-bg; + border-top-width: 1px; + border-left-width: 1px; + margin:2px; + padding:3px; +} + +.tabs-bar__link.active { + @include win95-inset(); + color:black; +} + +.tabs-bar__link:last-child::before { + content:"Start"; + color:black; + font-weight:bold; + font-size:15px; + width:80%; + display:block; + position:absolute; + right:0px; +} + +.tabs-bar__link:last-child { + position:relative; + flex-basis:60px !important; + font-size:0px; + color:$win95-bg; + + background-image: url("~images/start.png"); + background-repeat:no-repeat; + background-position:8%; + background-clip:padding-box; + background-size:auto 50%; +} + +.drawer .drawer__inner { + overflow: visible; + height:inherit; + background:$win95-bg; +} + +.drawer:after { + display:block; + content: " "; + + position:absolute; + bottom:15px; + left:15px; + width:132px; + height:117px; + background-image:url("~images/clippy_wave.gif"), url("~images/clippy_frame.png"); + background-repeat:no-repeat; + background-position: 4px 20px, 0px 0px; + z-index:0; +} + +.drawer__pager { + overflow-y:auto; + z-index:1; +} + +.privacy-dropdown__dropdown { + z-index:2; +} + +.column { + max-height:100vh; +} + +.column > .scrollable { + background: $win95-bg; + @include win95-border-outset(); + border-top-width:0px; +} + +.column-header__wrapper { + color:white; + font-weight:bold; + background:#7f7f7f; +} + +.column-header { + padding:2px; + font-size:13px; + background:#7f7f7f; + @include win95-border-outset(); + border-bottom-width:0px; + color:white; + font-weight:bold; + align-items:baseline; +} + +.column-header__wrapper.active { + background:$win95-window-header; +} + +.column-header__wrapper.active::before { + display:none; +} +.column-header.active { + box-shadow:unset; + background:$win95-window-header; +} + +.column-header.active .column-header__icon { + color:white; +} + +.column-header__buttons { + max-height: 20px; + margin-right:0px; +} + +.column-header__button { + background: $win95-bg; + color: black; + line-height:0px; + font-size:14px; + max-height:20px; + padding:0px 2px; + margin-top:2px; + @include win95-outset(); + + &:hover { + color: black; + } +} + +.column-header__button.active, .column-header__button.active:hover { + @include win95-inset(); + background-color:#7f7f7f; +} + +.column-header__back-button { + background: $win95-bg; + color: black; + padding:2px; + max-height:20px; + margin-top:2px; + @include win95-outset(); + font-size:13px; + font-weight:bold; +} + +.column-back-button { + background:$win95-bg; + color:black; + @include win95-outset(); + padding:2px; + font-size:13px; + font-weight:bold; +} + +.column-back-button--slim-button { + position:absolute; + top:-22px; + right:4px; + max-height:20px; + max-width:60px; + padding:0px 2px; +} + +.column-back-button__icon { + font-size:11px; + margin-top:-3px; +} + +.column-header__collapsible { + border-left:2px outset $win95-bg; + border-right:2px outset $win95-bg; +} + +.column-header__collapsible-inner { + background:$win95-bg; + color:black; +} + +.column-header__collapsible__extra { + color:black; +} + +.column-header__collapsible__extra div[role="group"] { + border: 2px groove $win95-bg; + border-radius:4px; + margin-bottom:8px; + padding:4px; +} + +.column-inline-form { + background-color: $win95-bg; + @include win95-border-outset(); + border-bottom-width:0px; + border-top-width:0px; +} + +.column-settings__section { + color:black; + font-weight:bold; + font-size:11px; + position:relative; + top: -12px; + left:4px; + background-color:$win95-bg; + display:inline-block; + padding:0px 4px; + margin-bottom:0px; +} + +.setting-meta__label, .setting-toggle__label { + color:black; + font-weight:normal; +} + +.setting-meta__label span:before { + content:"("; +} +.setting-meta__label span:after { + content:")"; +} + +.setting-toggle { + line-height:13px; +} + +.react-toggle .react-toggle-track { + border-radius:0px; + background-color:white; + @include win95-border-inset(); + + width:12px; + height:12px; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color:white; +} + +.react-toggle .react-toggle-track-check { + left:2px; + transition:unset; +} + +.react-toggle .react-toggle-track-check svg path { + fill: black; +} + +.react-toggle .react-toggle-track-x { + display:none; +} + +.react-toggle .react-toggle-thumb { + border-radius:0px; + display:none; +} + +.text-btn { + background-color:$win95-bg; + @include win95-outset(); + padding:4px; +} + +.text-btn:hover { + text-decoration:none; + color:black; +} + +.text-btn:active { + @include win95-inset(); +} + +.setting-text { + color:black; + background-color:white; + @include win95-inset(); + font-size:13px; + padding:2px; +} + +.setting-text:active, .setting-text:focus, +.setting-text.light:active, .setting-text.light:focus { + color:black; + border-bottom:2px inset $win95-bg; +} + +.column-header__setting-arrows .column-header__setting-btn { + padding:3px 10px; +} + +.column-header__setting-arrows .column-header__setting-btn:last-child { + padding:3px 10px; +} + +.missing-indicator { + background-color:$win95-bg; + color:black; + @include win95-outset(); +} + +.missing-indicator > div { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRUaXRsZQAACJnLyy9Jyy/NSwEAD5IDblIFOhoAAAAXelRYdEF1dGhvcgAACJlLzijKz0vMAQALmgLoDsFj8gAAAQpJREFUOMuVlD0OwjAMhd2oQl04Axfo0IGBgYELcAY6cqQuSO0ZOEAZGBg6VKg74gwsEaoESRVHjusI8aQqzY8/PbtOEz1qkFSn2YevlaNOpLMJh2DwvixhuXtOa6/LCh51DUMEFkAsgAZD207Doin8mQ562JpRE5CHBAAhmIqD1L8AqzUUUJkxc6kr3AgAJ+NuvIWRdk7WcrKl0AUqcIBBHOiEbpS4m27mIL5Onfg3k0rgggeQuS2sDOGSahKR+glgqaGLgUJs951NN1q9D72cQqQWR9cr3sm9YcEssEuz6eEuZh2bu0aSOhQ1MBezu2O/+TVSvEFII3qLsZWrSA2AAUQIh1HpyP/kC++zjVSMj6ntAAAAAElFTkSuQmCC') + no-repeat; + background-position:center center; +} + +.empty-column-indicator, +.error-column { + background: $win95-bg; + color: black; +} + +.status__wrapper { + border: 2px groove $win95-bg; + margin:4px; +} + +.status { + @include win95-border-slight-inset(); + background-color:white; + margin:4px; + padding-bottom:40px; + margin-bottom:8px; +} + +.status.status-direct { + background-color:$win95-bg; +} + +.status__content { + font-size:13px; +} + +.status.light .status__relative-time, +.status.light .display-name span { + color: #7f7f7f; +} + +.status__action-bar { + box-sizing:border-box; + position:absolute; + bottom:-1px; + left:-1px; + background:$win95-bg; + width:calc(100% + 2px); + padding-left:10px; + padding: 4px 2px; + padding-bottom:4px; + border-bottom:2px groove $win95-bg; + border-top:1px outset $win95-bg; + text-align: right; +} + +.status__wrapper .status__action-bar { + border-bottom-width:0px; +} + +.status__action-bar-button { + float:right; +} + +.status__action-bar-dropdown { + margin-left:auto; + margin-right:10px; + + .icon-button { + min-width:28px; + } +} +.status.light .status__content a { + color:blue; +} + +.focusable:focus { + background: $win95-bg; + .detailed-status__action-bar { + background: $win95-bg; + } + + .status, .detailed-status { + background: white; + outline:2px dotted $win95-mid-grey; + } +} + +.dropdown__trigger.icon-button { + padding-right:6px; +} + +.detailed-status__action-bar-dropdown .icon-button { + min-width:28px; +} + +.detailed-status { + background:white; + background-clip:padding-box; + margin:4px; + border: 2px groove $win95-bg; + padding:4px; +} + +.detailed-status__display-name { + color:#7f7f7f; +} + +.detailed-status__display-name strong { + color:black; + font-weight:bold; +} +.account__avatar, +.account__avatar-overlay-base, +.account__header__avatar, +.account__avatar-overlay-overlay { + @include win95-border-slight-inset(); + clip-path:none; + filter: saturate(1.8) brightness(1.1); +} + +.detailed-status__action-bar { + background-color:$win95-bg; + border:0px; + border-bottom:2px groove $win95-bg; + margin-bottom:8px; + justify-items:left; + padding-left:4px; +} +.icon-button { + background:$win95-bg; + @include win95-border-outset(); + padding:0px 0px 0px 0px; + margin-right:4px; + + color:#3f3f3f; + &.inverted, &:hover, &.inverted:hover, &:active, &:focus { + color:#3f3f3f; + } +} + +.icon-button:active { + @include win95-border-inset(); +} + +.status__action-bar > .icon-button { + padding:0px 15px 0px 0px; + min-width:25px; +} + +.icon-button.star-icon, +.icon-button.star-icon:active { + background:transparent; + border:none; +} + +.icon-button.star-icon.active { + color: $gold-star; + &:active, &:hover, &:focus { + color: $gold-star; + } +} + +.icon-button.star-icon > i { + background:$win95-bg; + @include win95-border-outset(); + padding-bottom:3px; +} + +.icon-button.star-icon:active > i { + @include win95-border-inset(); +} + +.text-icon-button { + color:$win95-dark-grey; +} + +.detailed-status__action-bar-dropdown { + margin-left:auto; + justify-content:right; + padding-right:16px; +} + +.detailed-status__button { + flex:0 0 auto; +} + +.detailed-status__button .icon-button { + padding-left:2px; + padding-right:25px; +} + +.status-card { + border-radius:0px; + background:white; + border: 1px solid black; + color:black; +} + +.status-card:hover { + background-color:white; +} + +.status-card__title { + color:blue; + text-decoration:underline; + font-weight:bold; +} + +.load-more { + width:auto; + margin:5px auto; + background: $win95-bg; + @include win95-outset(); + color:black; + padding: 2px 5px; + + &:hover { + background: $win95-bg; + color:black; + } +} + +.status-card__description { + color:black; +} + +.account__display-name strong, .status__display-name strong { + color:black; + font-weight:bold; +} + +.account .account__display-name { + color:black; +} + +.account { + border-bottom: 2px groove $win95-bg; +} + +.reply-indicator__content .status__content__spoiler-link, .status__content .status__content__spoiler-link { + background:$win95-bg; + @include win95-outset(); +} + +.reply-indicator__content .status__content__spoiler-link:hover, .status__content .status__content__spoiler-link:hover { + background:$win95-bg; +} + +.reply-indicator__content .status__content__spoiler-link:active, .status__content .status__content__spoiler-link:active { + @include win95-inset(); +} + +.reply-indicator__content a, .status__content a { + color:blue; +} + +.notification { + border: 2px groove $win95-bg; + margin:4px; +} + +.notification__message { + color:black; + font-size:13px; +} + +.notification__display-name { + font-weight:bold; +} + +.drawer__header { + background: $win95-bg; + @include win95-border-outset(); + justify-content:left; + margin-bottom:0px; + padding-bottom:2px; + border-bottom:2px groove $win95-bg; +} + +.drawer__tab { + color:black; + @include win95-outset(); + padding:5px; + margin:2px; + flex: 0 0 auto; +} + +.drawer__tab:first-child::before { + content:"Start"; + color:black; + font-weight:bold; + font-size:15px; + width:80%; + display:block; + position:absolute; + right:0px; + +} + +.drawer__tab:first-child { + position:relative; + padding:5px 15px; + width:40px; + font-size:0px; + color:$win95-bg; + + background-image: url("~images/start.png"); + background-repeat:no-repeat; + background-position:8%; + background-clip:padding-box; + background-size:auto 50%; +} + +.drawer__header a:hover { + background-color:transparent; +} + +.drawer__header a:first-child:hover { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII="); + background-repeat:no-repeat; + background-position:8%; + background-clip:padding-box; + background-size:auto 50%; + transition:unset; +} + +.drawer__tab:first-child { + +} + +.search { + background:$win95-bg; + padding-top:2px; + padding:2px; + border:2px outset $win95-bg; + border-top-width:0px; + border-bottom: 2px groove $win95-bg; + margin-bottom:0px; +} + +.search input { + background-color:white; + color:black; + @include win95-border-slight-inset(); +} + +.search__input:focus { + background-color:white; +} + +.search-popout { + box-shadow: unset; + color:black; + border-radius:0px; + background-color:$win95-tooltip-yellow; + border:1px solid black; + + h4 { + color:black; + text-transform: none; + font-weight:bold; + } +} + +.search-results__header { + background-color: $win95-bg; + color:black; + border-bottom:2px groove $win95-bg; +} + +.search-results__hashtag { + color:blue; +} + +.search-results__section .account:hover, +.search-results__section .account:hover .account__display-name, +.search-results__section .account:hover .account__display-name strong, +.search-results__section .search-results__hashtag:hover { + background-color:$win95-window-header; + color:white; +} + +.search__icon .fa { + color:#808080; + + &.active { + opacity:1.0; + } + + &:hover { + color: #808080; + } +} + +.drawer__inner, +.drawer__inner.darker { + background-color:$win95-bg; + border: 2px outset $win95-bg; + border-top-width:0px; +} + +.navigation-bar { + color:black; +} + +.navigation-bar strong { + color:black; + font-weight:bold; +} + +.compose-form .autosuggest-textarea__textarea, +.compose-form .spoiler-input__input { + border-radius:0px; + @include win95-border-slight-inset(); +} + +.compose-form .autosuggest-textarea__textarea { + border-bottom:0px; +} + +.compose-form__uploads-wrapper { + border-radius:0px; + border-bottom:1px inset $win95-bg; + border-top-width:0px; +} + +.compose-form__upload-wrapper { + border-left:1px inset $win95-bg; + border-right:1px inset $win95-bg; +} + +.compose-form .compose-form__buttons-wrapper { + background-color: $win95-bg; + border:2px groove $win95-bg; + margin-top:4px; + padding:4px 8px; +} + +.compose-form__buttons { + background-color:$win95-bg; + border-radius:0px; + box-shadow:unset; +} + +.compose-form__buttons-separator { + border-left: 2px groove $win95-bg; +} + +.privacy-dropdown.active .privacy-dropdown__value.active, +.advanced-options-dropdown.open .advanced-options-dropdown__value { + background: $win95-bg; +} + +.privacy-dropdown.active .privacy-dropdown__value.active .icon-button { + color: $win95-dark-grey; +} + +.privacy-dropdown.active +.privacy-dropdown__value { + background: $win95-bg; + box-shadow:unset; +} + +.privacy-dropdown__option.active, .privacy-dropdown__option:hover, +.privacy-dropdown__option.active:hover { + background:$win95-window-header; +} + +.privacy-dropdown__dropdown, +.privacy-dropdown.active .privacy-dropdown__dropdown, +.advanced-options-dropdown__dropdown, +.advanced-options-dropdown.open .advanced-options-dropdown__dropdown +{ + box-shadow:unset; + color:black; + @include win95-outset(); + background: $win95-bg; +} + +.privacy-dropdown__option__content { + color:black; +} + +.privacy-dropdown__option__content strong { + font-weight:bold; +} + +.compose-form__warning::before { + content:"Tip:"; + font-weight:bold; + display:block; + position:absolute; + top:-10px; + background-color:$win95-bg; + font-size:11px; + padding: 0px 5px; +} + +.compose-form__warning { + position:relative; + box-shadow:unset; + border:2px groove $win95-bg; + background-color:$win95-bg; + color:black; +} + +.compose-form__warning a { + color:blue; +} + +.compose-form__warning strong { + color:black; + text-decoration:underline; +} + +.compose-form__buttons button.active:last-child { + @include win95-border-inset(); + background: #dfdfdf; + color:#7f7f7f; +} + +.compose-form__upload-thumbnail { + border-radius:0px; + border:2px groove $win95-bg; + background-color:$win95-bg; + padding:2px; + box-sizing:border-box; +} + +.compose-form__upload-thumbnail .icon-button { + max-width:20px; + max-height:20px; + line-height:10px !important; +} + +.compose-form__upload-thumbnail .icon-button::before { + content:"X"; + font-size:13px; + font-weight:bold; + color:black; +} + +.compose-form__upload-thumbnail .icon-button i { + display:none; +} + +.emoji-picker-dropdown__menu { + z-index:2; +} + +.emoji-dialog.with-search { + box-shadow:unset; + border-radius:0px; + background-color:$win95-bg; + border:1px solid black; + box-sizing:content-box; + +} + +.emoji-dialog .emoji-search { + color:black; + background-color:white; + border-radius:0px; + @include win95-inset(); +} + +.emoji-dialog .emoji-search-wrapper { + border-bottom:2px groove $win95-bg; +} + +.emoji-dialog .emoji-category-title { + color:black; + font-weight:bold; +} + +.reply-indicator { + background-color:$win95-bg; + border-radius:3px; + border:2px groove $win95-bg; +} + +.button { + background-color:$win95-bg; + @include win95-outset(); + border-radius:0px; + color:black; + font-weight:bold; + + &:hover, &:focus, &:disabled { + background-color:$win95-bg; + } + + &:active { + @include win95-inset(); + } + + &:disabled { + color: #808080; + text-shadow: 1px 1px 0px #efefef; + + &:active { + @include win95-outset(); + } + } + +} + +#Getting-started { + background-color:$win95-bg; + @include win95-inset(); + border-bottom-width:0px; +} + +#Getting-started::before { + content:"Start"; + color:black; + font-weight:bold; + font-size:15px; + width:80%; + text-align:center; + display:block; + position:absolute; + right:2px; +} + +#Getting-started { + position:relative; + padding:5px 15px; + width:60px; + font-size:0px; + color:$win95-bg; + + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII="); + background-repeat:no-repeat; + background-position:8%; + background-clip:padding-box; + background-size:auto 50%; +} + +.column-subheading { + background-color:$win95-bg; + color:black; + border-bottom: 2px groove $win95-bg; + text-transform: none; + font-size: 16px; +} + +.column-link { + background-color:transparent; + color:black; + &:hover { + background-color: $win95-window-header; + color:white; + } +} + +.getting-started__wrapper { + .column-subheading { + font-size:0px; + margin:0px; + padding:0px; + } + + .column-link { + background-size:32px 32px; + background-repeat:no-repeat; + background-position: 36px 50%; + padding-left:40px; + + &:hover { + background-size:32px 32px; + background-repeat:no-repeat; + background-position: 36px 50%; + } + + i { + font-size: 0px; + width:32px; + } + } +} + +.column-link[href="/web/timelines/public"] { + background-image: url("~images/icon_public.png"); + &:hover { background-image: url("~images/icon_public.png"); } +} +.column-link[href="/web/timelines/public/local"] { + background-image: url("~images/icon_local.png"); + &:hover { background-image: url("~images/icon_local.png"); } +} +.column-link[href="/web/pinned"] { + background-image: url("~images/icon_pin.png"); + &:hover { background-image: url("~images/icon_pin.png"); } +} +.column-link[href="/web/favourites"] { + background-image: url("~images/icon_likes.png"); + &:hover { background-image: url("~images/icon_likes.png"); } +} +.column-link[href="/web/lists"] { + background-image: url("~images/icon_lists.png"); + &:hover { background-image: url("~images/icon_lists.png"); } +} +.column-link[href="/web/follow_requests"] { + background-image: url("~images/icon_follow_requests.png"); + &:hover { background-image: url("~images/icon_follow_requests.png"); } +} +.column-link[href="/web/keyboard-shortcuts"] { + background-image: url("~images/icon_keyboard_shortcuts.png"); + &:hover { background-image: url("~images/icon_keyboard_shortcuts.png"); } +} +.column-link[href="/web/blocks"] { + background-image: url("~images/icon_blocks.png"); + &:hover { background-image: url("~images/icon_blocks.png"); } +} +.column-link[href="/web/mutes"] { + background-image: url("~images/icon_mutes.png"); + &:hover { background-image: url("~images/icon_mutes.png"); } +} +.column-link[href="/settings/preferences"] { + background-image: url("~images/icon_settings.png"); + &:hover { background-image: url("~images/icon_settings.png"); } +} +.column-link[href="/about/more"] { + background-image: url("~images/icon_about.png"); + &:hover { background-image: url("~images/icon_about.png"); } +} +.column-link[href="/auth/sign_out"] { + background-image: url("~images/icon_logout.png"); + &:hover { background-image: url("~images/icon_logout.png"); } +} + +.getting-started__footer { + display:none; +} + +.getting-started__wrapper::before { + content:"Mastodon 95"; + font-weight:bold; + font-size:23px; + color:white; + line-height:30px; + padding-left:20px; + padding-right:40px; + + left:0px; + bottom:-30px; + display:block; + position:absolute; + background-color:#7f7f7f; + width:200%; + height:30px; + + -ms-transform: rotate(-90deg); + + -webkit-transform: rotate(-90deg); + transform: rotate(-90deg); + transform-origin:top left; +} + +.getting-started__wrapper { + @include win95-border-outset(); + background-color:$win95-bg; +} + +.column .static-content.getting-started { + display:none; +} + +.keyboard-shortcuts kbd { + background-color: $win95-bg; +} + +.account__header { + background-color:#7f7f7f; +} + +.account__header .account__header__content { + color:white; +} + +.account-authorize__wrapper { + border: 2px groove $win95-bg; + margin: 2px; + padding:2px; +} + +.account--panel { + background-color: $win95-bg; + border:0px; + border-top: 2px groove $win95-bg; +} + +.account-authorize .account__header__content { + color:black; + margin:10px; +} + +.account__action-bar__tab > span { + color:black; + font-weight:bold; +} + +.account__action-bar__tab strong { + color:black; +} + +.account__action-bar { + border: unset; +} + +.account__action-bar__tab { + border: 1px outset $win95-bg; +} + +.account__action-bar__tab:active { + @include win95-inset(); +} + +.dropdown--active .dropdown__content > ul, +.dropdown-menu { + background:$win95-tooltip-yellow; + border-radius:0px; + border:1px solid black; + box-shadow:unset; +} + +.dropdown-menu a { + background-color:transparent; +} + +.dropdown--active::after { + display:none; +} + +.dropdown--active .icon-button { + color:black; + @include win95-inset(); +} + +.dropdown--active .dropdown__content > ul > li > a { + background:transparent; +} + +.dropdown--active .dropdown__content > ul > li > a:hover { + background:transparent; + color:black; + text-decoration:underline; +} + +.dropdown__sep, +.dropdown-menu__separator +{ + border-color:#7f7f7f; +} + +.detailed-status__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__left { + left:unset; +} + +.dropdown > .icon-button, .detailed-status__button > .icon-button, +.status__action-bar > .icon-button, .star-icon i { + /* i don't know what's going on with the inline + styles someone should look at the react code */ + height: 25px !important; + width: 28px !important; + box-sizing: border-box; +} + +.status__action-bar-button .fa-floppy-o { + padding-top: 2px; +} + +.status__action-bar-dropdown { + position: relative; + top: -3px; +} + +.detailed-status__action-bar-dropdown .dropdown { + position: relative; + top: -4px; +} + +.notification .status__action-bar { + border-bottom: none; +} + +.notification .status { + margin-bottom: 4px; +} + +.status__wrapper .status { + margin-bottom: 3px; +} + +.status__wrapper { + margin-bottom: 8px; +} + +.icon-button .fa-retweet { + position: relative; + top: -1px; +} + +.embed-modal, .error-modal, .onboarding-modal, +.actions-modal, .boost-modal, .confirmation-modal, .report-modal { + @include win95-outset(); + background:$win95-bg; +} + +.actions-modal::before, +.boost-modal::before, +.confirmation-modal::before, +.report-modal::before { + content: "Confirmation"; + display:block; + background:$win95-window-header; + color:white; + font-weight:bold; + padding-left:2px; +} + +.boost-modal::before { + content: "Boost confirmation"; +} + +.boost-modal__action-bar > div > span:before { + content: "Tip: "; + font-weight:bold; +} + +.boost-modal__action-bar, .confirmation-modal__action-bar, .report-modal__action-bar { + background:$win95-bg; + margin-top:-15px; +} + +.embed-modal h4, .error-modal h4, .onboarding-modal h4 { + background:$win95-window-header; + color:white; + font-weight:bold; + padding:2px; + font-size:13px; + text-align:left; +} + +.confirmation-modal__action-bar { + .confirmation-modal__cancel-button { + color:black; + + &:active, + &:focus, + &:hover { + color:black; + } + + &:active { + @include win95-inset(); + } + } +} + +.embed-modal .embed-modal__container .embed-modal__html, +.embed-modal .embed-modal__container .embed-modal__html:focus { + background:white; + color:black; + @include win95-inset(); +} + +.modal-root__overlay, +.account__header > div { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFnpUWHRUaXRsZQAACJnLzU9JzElKBwALgwLXaCRlPwAAABd6VFh0QXV0aG9yAAAImUvOKMrPS8wBAAuaAugOwWPyAAAAEUlEQVQImWNgYGD4z4AE/gMADwMB/414xEUAAAAASUVORK5CYII='); +} + +.admin-wrapper::before { + position:absolute; + top:0px; + content:"Control Panel"; + color:white; + background-color:$win95-window-header; + font-size:13px; + font-weight:bold; + width:calc(100%); + margin: 2px; + display:block; + padding:2px; + padding-left:22px; + box-sizing:border-box; +} + +.admin-wrapper { + position:relative; + background: $win95-bg; + @include win95-outset(); + width:70vw; + height:80vh; + margin:10vh auto; + color: black; + padding-top:24px; + flex-direction:column; + overflow:hidden; +} + +@media screen and (max-width: 1120px) { + .admin-wrapper { + width:90vw; + height:95vh; + margin:2.5vh auto; + } +} + +@media screen and (max-width: 740px) { + .admin-wrapper { + width:100vw; + height:95vh; + height:calc(100vh - 24px); + margin:0px 0px 0px 0px; + } +} + +.admin-wrapper .sidebar-wrapper { + position:static; + height:auto; + flex: 0 0 auto; + margin:2px; +} + +.admin-wrapper .content-wrapper { + flex: 1 1 auto; + width:calc(100% - 20px); + @include win95-border-outset(); + position:relative; + margin-left:10px; + margin-right:10px; + margin-bottom:40px; + box-sizing:border-box; +} + +.admin-wrapper .content { + background-color: $win95-bg; + width: 100%; + max-width:100%; + min-height:100%; + box-sizing:border-box; + position:relative; +} + +.admin-wrapper .sidebar { + position:static; + background: $win95-bg; + color:black; + width: 100%; + height:auto; + padding-bottom: 20px; +} + +.admin-wrapper .sidebar .logo { + position:absolute; + top:2px; + left:4px; + width:18px; + height:18px; + margin:0px; +} + +.admin-wrapper .sidebar > ul { + background: $win95-bg; + margin:0px; + margin-left:8px; + color:black; + + & > li { + display:inline-block; + + &#settings, + &#admin { + padding:2px; + border: 0px solid transparent; + } + + &#logout { + position:absolute; + @include win95-outset(); + right:12px; + bottom:10px; + } + + &#web { + display:inline-block; + @include win95-outset(); + position:absolute; + left: 12px; + bottom: 10px; + } + + & > a { + display:inline-block; + @include win95-tab(); + padding:2px 5px; + margin:0px; + color:black; + vertical-align:baseline; + + &.selected { + background: $win95-bg; + color:black; + padding-top: 4px; + padding-bottom:4px; + } + + &:hover { + background: $win95-bg; + color:black; + } + } + + & > ul { + width:calc(100% - 20px); + background: transparent; + position:absolute; + left: 10px; + top:54px; + z-index:3; + + & > li { + background: $win95-bg; + display: inline-block; + vertical-align:baseline; + + & > a { + background: $win95-bg; + @include win95-tab(); + color:black; + padding:2px 5px; + position:relative; + z-index:3; + + &.selected { + background: $win95-bg; + color:black; + padding-bottom:4px; + padding-top: 4px; + padding-right:7px; + margin-left:-2px; + margin-right:-2px; + position:relative; + z-index:4; + + &:first-child { + margin-left:0px; + } + + &:hover { + background: transparent; + color:black; + } + } + + &:hover { + background: $win95-bg; + color:black; + } + } + } + } + } +} + +@media screen and (max-width: 1520px) { + .admin-wrapper .sidebar > ul > li > ul { + max-width:1000px; + } + + .admin-wrapper .sidebar { + padding-bottom: 45px; + } +} + +@media screen and (max-width: 600px) { + .admin-wrapper .sidebar > ul > li > ul { + max-width:500px; + } + + .admin-wrapper { + .sidebar { + padding:0px; + padding-bottom: 70px; + width: 100%; + height: auto; + } + .content-wrapper { + overflow:auto; + height:80%; + height:calc(100% - 150px); + } + } +} + +.flash-message { + background-color:$win95-tooltip-yellow; + color:black; + border:1px solid black; + border-radius:0px; + position:absolute; + top:0px; + left:0px; + width:100%; +} + +.admin-wrapper table { + background-color: white; + @include win95-border-slight-inset(); +} + +.admin-wrapper .content h2, +.simple_form .input.with_label .label_input > label, +.admin-wrapper .content h6, +.admin-wrapper .content > p, +.admin-wrapper .content .muted-hint, +.simple_form span.hint, +.simple_form h4, +.simple_form .check_boxes .checkbox label, +.simple_form .input.with_label.boolean .label_input > label, +.filters .filter-subset a, +.simple_form .input.radio_buttons .radio label, +a.table-action-link, +a.table-action-link:hover, +.simple_form .input.with_block_label > label, +.simple_form p.hint { + color:black; +} + +.table > tbody > tr:nth-child(2n+1) > td, +.table > tbody > tr:nth-child(2n+1) > th { + background-color:white; +} + +.simple_form input[type=text], +.simple_form input[type=number], +.simple_form input[type=email], +.simple_form input[type=password], +.simple_form textarea { + color:black; + background-color:white; + @include win95-border-slight-inset(); + + &:active, &:focus { + background-color:white; + } +} + +.simple_form button, +.simple_form .button, +.simple_form .block-button +{ + background: $win95-bg; + @include win95-outset(); + color:black; + font-weight: normal; + + &:hover { + background: $win95-bg; + } +} + +.simple_form .warning, .table-form .warning +{ + background: $win95-tooltip-yellow; + color:black; + box-shadow: unset; + text-shadow:unset; + border:1px solid black; + + a { + color: blue; + text-decoration:underline; + } +} + +.simple_form button.negative, +.simple_form .button.negative, +.simple_form .block-button.negative +{ + background: $win95-bg; +} + +.filters .filter-subset { + border: 2px groove $win95-bg; + padding:2px; +} + +.filters .filter-subset a::before { + content: ""; + background-color:white; + border-radius:50%; + border:2px solid black; + border-top-color:#7f7f7f; + border-left-color:#7f7f7f; + border-bottom-color:#f5f5f5; + border-right-color:#f5f5f5; + width:12px; + height:12px; + display:inline-block; + vertical-align:middle; + margin-right:2px; +} + +.filters .filter-subset a.selected::before { + background-color:black; + box-shadow: inset 0 0 0 3px white; +} + +.filters .filter-subset a, +.filters .filter-subset a:hover, +.filters .filter-subset a.selected { + color:black; + border-bottom: 0px solid transparent; +} diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index d77cdb3a3..4bc75dae8 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -43,7 +43,8 @@ class FeedManager def push_to_list(list, status) if status.reply? && status.in_reply_to_account_id != status.account_id should_filter = status.in_reply_to_account_id != list.account_id - should_filter &&= !ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists? + should_filter &&= !list.show_all_replies? + should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) return false if should_filter end return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb index 1bba4a5a6..23d0a418f 100644 --- a/app/lib/sanitize_config.rb +++ b/app/lib/sanitize_config.rb @@ -20,11 +20,13 @@ class Sanitize end MASTODON_STRICT ||= freeze_config( - elements: %w(p br span a), + elements: %w(p br span a abbr del pre blockquote code b strong u sub i em h1 h2 h3 h4 h5 ul ol li), attributes: { - 'a' => %w(href rel class), - 'span' => %w(class), + 'a' => %w(href rel class title), + 'span' => %w(class), + 'abbr' => %w(title), + 'blockquote' => %w(cite), }, add_attributes: { @@ -35,7 +37,8 @@ class Sanitize }, protocols: { - 'a' => { 'href' => HTTP_PROTOCOLS }, + 'a' => { 'href' => HTTP_PROTOCOLS }, + 'blockquote' => { 'cite' => HTTP_PROTOCOLS }, }, transformers: [ diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb index 3653ab114..343996e8a 100644 --- a/app/lib/settings/scoped_settings.rb +++ b/app/lib/settings/scoped_settings.rb @@ -3,7 +3,8 @@ module Settings class ScopedSettings DEFAULTING_TO_UNSCOPED = %w( - theme + flavour + skin ).freeze def initialize(object) diff --git a/app/lib/themes.rb b/app/lib/themes.rb index 243ffb9ab..2147904e4 100644 --- a/app/lib/themes.rb +++ b/app/lib/themes.rb @@ -7,10 +7,83 @@ class Themes include Singleton def initialize - @conf = YAML.load_file(Rails.root.join('config', 'themes.yml')) + + core = YAML.load_file(Rails.root.join('app', 'javascript', 'core', 'theme.yml')) + core['pack'] = Hash.new unless core['pack'] + + result = Hash.new + Dir.glob(Rails.root.join('app', 'javascript', 'flavours', '*', 'theme.yml')) do |path| + data = YAML.load_file(path) + dir = File.dirname(path) + name = File.basename(dir) + locales = [] + screenshots = [] + if data['locales'] + Dir.glob(File.join(dir, data['locales'], '*.{js,json}')) do |locale| + localeName = File.basename(locale, File.extname(locale)) + locales.push(localeName) unless localeName.match(/defaultMessages|whitelist|index/) + end + end + if data['screenshot'] + if data['screenshot'].is_a? Array + screenshots = data['screenshot'] + else + screenshots.push(data['screenshot']) + end + end + if data['pack'] + data['name'] = name + data['locales'] = locales + data['screenshot'] = screenshots + data['skin'] = { 'default' => [] } + result[name] = data + end + end + + Dir.glob(Rails.root.join('app', 'javascript', 'skins', '*', '*')) do |path| + ext = File.extname(path) + skin = File.basename(path) + name = File.basename(File.dirname(path)) + if result[name] + if File.directory?(path) + pack = [] + Dir.glob(File.join(path, '*.{css,scss}')) do |sheet| + pack.push(File.basename(sheet, File.extname(sheet))) + end + elsif ext.match(/^\.s?css$/i) + skin = File.basename(path, ext) + pack = ['common'] + end + if skin != 'default' + result[name]['skin'][skin] = pack + end + end + end + + @core = core + @conf = result + + end + + def core + @core end - def names + def flavour(name) + @conf[name] + end + + def flavours @conf.keys end + + def skins_for(name) + @conf[name]['skin'].keys + end + + def flavours_and_skins + flavours.map do |flavour| + [flavour, skins_for(flavour).map{ |skin| [flavour, skin] }] + end + end end diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index daeb3d936..367ba9a83 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -22,6 +22,7 @@ class UserSettingsDecorator user.settings['default_language'] = default_language_preference if change?('setting_default_language') user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') + user.settings['favourite_modal'] = favourite_modal_preference if change?('setting_favourite_modal') user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') user.settings['display_media'] = display_media_preference if change?('setting_display_media') @@ -29,7 +30,9 @@ class UserSettingsDecorator user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion') user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') user.settings['noindex'] = noindex_preference if change?('setting_noindex') - user.settings['theme'] = theme_preference if change?('setting_theme') + user.settings['hide_followers_count']= hide_followers_count_preference if change?('setting_hide_followers_count') + user.settings['flavour'] = flavour_preference if change?('setting_flavour') + user.settings['skin'] = skin_preference if change?('setting_skin') user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network') user.settings['aggregate_reblogs'] = aggregate_reblogs_preference if change?('setting_aggregate_reblogs') user.settings['show_application'] = show_application_preference if change?('setting_show_application') @@ -59,6 +62,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_boost_modal' end + def favourite_modal_preference + boolean_cast_setting 'setting_favourite_modal' + end + def delete_modal_preference boolean_cast_setting 'setting_delete_modal' end @@ -87,6 +94,18 @@ class UserSettingsDecorator boolean_cast_setting 'setting_noindex' end + def hide_followers_count_preference + boolean_cast_setting 'setting_hide_followers_count' + end + + def flavour_preference + settings['setting_flavour'] + end + + def skin_preference + settings['setting_skin'] + end + def hide_network_preference boolean_cast_setting 'setting_hide_network' end @@ -95,10 +114,6 @@ class UserSettingsDecorator boolean_cast_setting 'setting_show_application' end - def theme_preference - settings['setting_theme'] - end - def default_language_preference settings['setting_default_language'] end diff --git a/app/models/account.rb b/app/models/account.rb index 51e01246e..a82251d2e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -62,6 +62,10 @@ class Account < ApplicationRecord include AccountCounters include DomainNormalizable + MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i + MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i + MAX_FIELDS = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i + enum protocol: [:ostatus, :activitypub] validates :username, presence: true @@ -74,9 +78,9 @@ class Account < ApplicationRecord validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? } validates_with UniqueUsernameValidator, 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, note_length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? } - validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? } + validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? } + validates :note, note_length: { maximum: MAX_NOTE_LENGTH }, if: -> { local? && will_save_change_to_note? } + validates :fields, length: { maximum: MAX_FIELDS }, if: -> { local? && will_save_change_to_fields? } scope :remote, -> { where.not(domain: nil) } scope :local, -> { where(domain: nil) } @@ -262,15 +266,13 @@ class Account < ApplicationRecord self[:fields] = fields end - DEFAULT_FIELDS_SIZE = 4 - def build_fields - return if fields.size >= DEFAULT_FIELDS_SIZE + return if fields.size >= MAX_FIELDS tmp = self[:fields] || [] tmp = [] if tmp.is_a?(Hash) - (DEFAULT_FIELDS_SIZE - tmp.size).times do + (MAX_FIELDS - tmp.size).times do tmp << { name: '', value: '' } end diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb new file mode 100644 index 000000000..916261a17 --- /dev/null +++ b/app/models/bookmark.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: bookmarks +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# status_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Bookmark < ApplicationRecord + include Paginable + + update_index('statuses#status', :status) if Chewy.enabled? + + belongs_to :account, inverse_of: :bookmarks + belongs_to :status, inverse_of: :bookmarks + + validates :status_id, uniqueness: { scope: :account_id } + + before_validation do + self.status = status.reblog if status&.reblog? + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 70855e054..ecccaf35e 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -14,6 +14,7 @@ module AccountAssociations has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :statuses, inverse_of: :account, dependent: :destroy has_many :favourites, inverse_of: :account, dependent: :destroy + has_many :bookmarks, inverse_of: :account, dependent: :destroy has_many :mentions, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ad2909d91..f27d39483 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -186,6 +186,10 @@ module AccountInteractions status.proper.favourites.where(account: self).exists? end + def bookmarked?(status) + status.proper.bookmarks.where(account: self).exists? + end + def reblogged?(status) status.proper.reblogs.where(account: self).exists? end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 86a86ec66..0e9bfb265 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -17,7 +17,8 @@ class Form::AdminSettings timeline_preview show_staff_badge bootstrap_timeline_accounts - theme + flavour + skin min_invite_role activity_api_enabled peers_api_enabled @@ -25,9 +26,14 @@ class Form::AdminSettings preview_sensitive_media custom_css profile_directory + hide_followers_count + enable_keybase + flavour_and_skin thumbnail hero mascot + show_reblogs_in_public_timelines + show_replies_in_public_timelines ).freeze BOOLEAN_KEYS = %i( @@ -39,6 +45,10 @@ class Form::AdminSettings show_known_fediverse_at_about_page preview_sensitive_media profile_directory + hide_followers_count + enable_keybase + show_reblogs_in_public_timelines + show_replies_in_public_timelines ).freeze UPLOAD_KEYS = %i( @@ -47,6 +57,10 @@ class Form::AdminSettings mascot ).freeze + PSEUDO_KEYS = %i( + flavour_and_skin + ).freeze + attr_accessor(*KEYS) validates :site_short_description, :site_description, html: { wrap_with: :p } @@ -66,6 +80,7 @@ class Form::AdminSettings return false unless valid? KEYS.each do |key| + next if PSEUDO_KEYS.include?(key) value = instance_variable_get("@#{key}") if UPLOAD_KEYS.include?(key) && !value.nil? @@ -78,10 +93,19 @@ class Form::AdminSettings end end + def flavour_and_skin + "#{Setting.flavour}/#{Setting.skin}" + end + + def flavour_and_skin=(value) + @flavour, @skin = value.split('/', 2) + end + private def initialize_attributes KEYS.each do |key| + next if PSEUDO_KEYS.include?(key) instance_variable_set("@#{key}", Setting.public_send(key)) if instance_variable_get("@#{key}").nil? end end diff --git a/app/models/list.rb b/app/models/list.rb index c9c94fca1..8493046e5 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -3,11 +3,12 @@ # # Table name: lists # -# id :bigint(8) not null, primary key -# account_id :bigint(8) not null -# title :string default(""), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# title :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# replies_policy :integer default("list_replies"), not null # class List < ApplicationRecord @@ -15,6 +16,8 @@ class List < ApplicationRecord PER_ACCOUNT_LIMIT = 50 + enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show + belongs_to :account, optional: true has_many :list_accounts, inverse_of: :list, dependent: :destroy diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index ab794faa0..65f00e1c8 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -24,14 +24,16 @@ 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', '.webp'].freeze VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].freeze + AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze + AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze BLURHASH_OPTIONS = { x_comp: 4, @@ -51,6 +53,22 @@ class MediaAttachment < ApplicationRecord }, }.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: { @@ -95,7 +113,7 @@ class MediaAttachment < ApplicationRecord processors: ->(f) { file_processors f }, convert_options: { all: '-quality 90 -strip' } - 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: IMAGE_LIMIT, unless: :video_or_gifv? validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video_or_gifv? remotable_attachment :file, VIDEO_LIMIT @@ -163,6 +181,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 elsif VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type) { small: VIDEO_STYLES[:small], @@ -178,6 +198,8 @@ class MediaAttachment < ApplicationRecord [:gif_transcoder, :blurhash_transcoder] elsif VIDEO_MIME_TYPES.include? f.file_content_type [:video_transcoder, :blurhash_transcoder] + elsif AUDIO_MIME_TYPES.include? f.file_content_type + [:audio_transcoder] else [:lazy_thumbnail, :blurhash_transcoder] end @@ -202,7 +224,7 @@ class MediaAttachment < ApplicationRecord end def set_type_and_extension - self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image + self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image end def set_meta diff --git a/app/models/mute.rb b/app/models/mute.rb index 0e00c2278..639120f7d 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -6,9 +6,9 @@ # id :bigint(8) not null, primary key # created_at :datetime not null # updated_at :datetime not null +# hide_notifications :boolean default(TRUE), not null # account_id :bigint(8) not null # target_account_id :bigint(8) not null -# hide_notifications :boolean default(TRUE), not null # class Mute < ApplicationRecord diff --git a/app/models/status.rb b/app/models/status.rb index 8d31fd382..1b905d5b8 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -21,6 +21,8 @@ # account_id :bigint(8) not null # application_id :bigint(8) # in_reply_to_account_id :bigint(8) +# local_only :boolean +# full_status_text :text default(""), not null # poll_id :bigint(8) # @@ -51,6 +53,7 @@ class Status < ApplicationRecord belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true has_many :favourites, inverse_of: :status, dependent: :destroy + has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy, inverse_of: :status @@ -100,6 +103,8 @@ class Status < ApplicationRecord end } + scope :not_local_only, -> { where(local_only: [false, nil]) } + cache_associated :application, :media_attachments, :conversation, @@ -257,6 +262,8 @@ class Status < ApplicationRecord around_create Mastodon::Snowflake::Callbacks + before_create :set_locality + before_validation :prepare_contents, if: :local? before_validation :set_reblog before_validation :set_visibility @@ -320,7 +327,8 @@ class Status < ApplicationRecord end def as_public_timeline(account = nil, local_only = false) - query = timeline_scope(local_only).without_replies + query = timeline_scope(local_only) + query = query.without_replies unless Setting.show_replies_in_public_timelines apply_timeline_filters(query, account, local_only) end @@ -339,6 +347,10 @@ class Status < ApplicationRecord Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true } end + def bookmarks_map(status_ids, account_id) + Bookmark.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h + end + def reblogs_map(status_ids, account_id) select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).each_with_object({}) { |s, h| h[s.reblog_of_id] = true } end @@ -375,7 +387,7 @@ class Status < ApplicationRecord visibility = [:public, :unlisted] if account.nil? - where(visibility: visibility) + where(visibility: visibility).not_local_only elsif target_account.blocking?(account) # get rid of blocked peeps none elsif account.id == target_account.id # author can see own stuff @@ -397,9 +409,12 @@ class Status < ApplicationRecord def timeline_scope(local_only = false) starting_scope = local_only ? Status.local : Status - starting_scope - .with_public_visibility - .without_reblogs + starting_scope = starting_scope.with_public_visibility + if Setting.show_reblogs_in_public_timelines + starting_scope + else + starting_scope.without_reblogs + end end def apply_timeline_filters(query, account, local_only) @@ -418,7 +433,7 @@ class Status < ApplicationRecord end def filter_timeline_default(query) - query.excluding_silenced_accounts + query.not_local_only.excluding_silenced_accounts end def account_silencing_filter(account) @@ -431,6 +446,15 @@ class Status < ApplicationRecord end end + def marked_local_only? + # match both with and without U+FE0F (the emoji variation selector) + /#{local_only_emoji}\ufe0f?\z/.match?(content) + end + + def local_only_emoji + '👁' + end + private def update_status_stat!(attrs) @@ -463,6 +487,12 @@ class Status < ApplicationRecord self.sensitive = false if sensitive.nil? end + def set_locality + if account.domain.nil? && !attribute_changed?(:local_only) + self.local_only = marked_local_only? + end + end + def set_conversation self.thread = thread.reblog if thread&.reblog? diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb index 1a9afc5c7..edd30487e 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/models/user.rb b/app/models/user.rb index bce28aa5f..77e6a33b5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -102,8 +102,8 @@ class User < ApplicationRecord has_many :session_activations, dependent: :destroy - delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, - :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, + delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal, + :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count, :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 64a5111fc..5e3282681 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -12,6 +12,8 @@ class StatusPolicy < ApplicationPolicy end def show? + return false if local_only? && (current_account.nil? || !current_account.local?) + if requires_mention? owned? || mention_exists? elsif private? @@ -84,4 +86,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 f3a73209a..534752932 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -40,6 +40,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/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index b04e10e2f..64e688d87 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -7,6 +7,7 @@ class StatusRelationshipsPresenter if current_account_id.nil? @reblogs_map = {} @favourites_map = {} + @bookmarks_map = {} @mutes_map = {} @pins_map = {} else @@ -17,6 +18,7 @@ class StatusRelationshipsPresenter @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) + @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 0c9fc625f..850169af8 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,10 +2,24 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, - :media_attachments, :settings + :media_attachments, :settings, + :max_toot_chars, :poll_limits has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer + def max_toot_chars + StatusLengthValidator::MAX_CHARS + end + + def poll_limits + { + max_options: PollValidator::MAX_OPTIONS, + max_option_chars: PollValidator::MAX_OPTION_CHARS, + min_expiration: PollValidator::MIN_EXPIRATION, + max_expiration: PollValidator::MAX_EXPIRATION, + } + end + def meta store = { streaming_api_base_url: Rails.configuration.x.streaming_api_base_url, @@ -26,6 +40,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:me] = object.current_account.id.to_s store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal store[:boost_modal] = object.current_account.user.setting_boost_modal + store[:favourite_modal] = object.current_account.user.setting_favourite_modal store[:delete_modal] = object.current_account.user.setting_delete_modal store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif store[:display_media] = object.current_account.user.setting_display_media @@ -59,7 +74,7 @@ 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 private diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 12adc971c..c34d23452 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -51,4 +51,8 @@ class REST::AccountSerializer < ActiveModel::Serializer def moved_and_not_nested? object.moved? && object.moved_to_account.moved_to_account_id.nil? end + + def followers_count + (Setting.hide_followers_count || object.user&.setting_hide_followers_count) ? -1 : object.followers_count + end end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index a82eff195..98c53c84a 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -4,7 +4,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer include RoutingHelper attributes :uri, :title, :description, :email, - :version, :urls, :stats, :thumbnail, + :version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits, :languages, :registrations has_one :contact_account, serializer: REST::AccountSerializer @@ -35,6 +35,19 @@ class REST::InstanceSerializer < ActiveModel::Serializer instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.jpg') end + def max_toot_chars + StatusLengthValidator::MAX_CHARS + end + + def poll_limits + { + max_options: PollValidator::MAX_OPTIONS, + max_option_chars: PollValidator::MAX_OPTION_CHARS, + min_expiration: PollValidator::MIN_EXPIRATION, + max_expiration: PollValidator::MAX_EXPIRATION, + } + end + def stats { user_count: instance_presenter.user_count, diff --git a/app/serializers/rest/list_serializer.rb b/app/serializers/rest/list_serializer.rb index 977da7439..3e87f7119 100644 --- a/app/serializers/rest/list_serializer.rb +++ b/app/serializers/rest/list_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::ListSerializer < ActiveModel::Serializer - attributes :id, :title + attributes :id, :title, :replies_policy def id object.id.to_s 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/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 106777b6e..a7b797368 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -9,7 +9,9 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? attribute :muted, if: :current_user? + attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? + attribute :local_only if :local? belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application, if: :show_application? @@ -90,6 +92,14 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def bookmarked + if instance_options && instance_options[:bookmarks] + instance_options[:bookmarks].bookmarks_map[object.id] || false + else + current_user.account.bookmarked?(object) + end + end + def pinned if instance_options && instance_options[:relationships] instance_options[:relationships].pins_map[object.id] || false diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 4cfa22ab8..5fcc98057 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -45,6 +45,7 @@ class BackupService < BaseService dump_media_attachments!(tar) dump_outbox!(tar) dump_likes!(tar) + dump_bookmarks!(tar) dump_actor!(tar) end end @@ -85,6 +86,7 @@ class BackupService < BaseService actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image] actor[:outbox] = 'outbox.json' actor[:likes] = 'likes.json' + actor[:bookmarks] = 'bookmarks.json' download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists? download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists? @@ -115,6 +117,25 @@ class BackupService < BaseService end end + def dump_bookmarks!(tar) + collection = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer) + + Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks).find_in_batches do |statuses| + statuses.each do |status| + collection[:totalItems] += 1 + collection[:orderedItems] << ActivityPub::TagManager.instance.uri_for(status) + end + + GC.start + end + + json = Oj.dump(collection) + + tar.add_file_simple('bookmarks.json', 0o444, json.bytesize) do |io| + io.write(json) + end + end + def collection_presenter ActivityPub::CollectionPresenter.new( id: 'outbox.json', diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index e328b1739..02f7076f7 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -46,6 +46,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 @@ -102,6 +103,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 f3e9c855d..b66dc342e 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -6,23 +6,27 @@ class FanOutOnWriteService < BaseService def call(status) raise Mastodon::RaceConditionError if status.visibility.nil? + 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) deliver_to_own_conversation(status) elsif status.limited_visibility? deliver_to_mentioned_followers(status) else - deliver_to_self(status) if status.account.local? deliver_to_followers(status) deliver_to_lists(status) end - return if status.account.silenced? || !status.public_visibility? || status.reblog? + return if status.account.silenced? || !status.public_visibility? + return if status.reblog? && !Setting.show_reblogs_in_public_timelines deliver_to_hashtags(status) - return if status.reply? && status.in_reply_to_account_id != status.account_id + return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines deliver_to_public(status) deliver_to_media(status) if status.media_attachments.any? @@ -91,6 +95,16 @@ class FanOutOnWriteService < BaseService Redis.current.publish('timeline:public:local:media', @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 + def deliver_to_own_conversation(status) AccountConversation.add_status(status.account, status) end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index e7366c7e8..333bf88d8 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -47,7 +47,10 @@ class PostStatusService < BaseService private def preprocess_attributes! - @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? + if @text.blank? && @options[:spoiler_text].present? + @text = '.' + @text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0 + end @visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = :unlisted if @visibility == :public && @account.silenced @scheduled_at = @options[:scheduled_at]&.to_datetime @@ -88,8 +91,12 @@ class PostStatusService < BaseService def postprocess_status! 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) + + unless @status.local_only? + Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(@status.id) + end + PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 2595c5fd3..1d9448e21 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -48,9 +48,9 @@ class ProcessMentionsService < BaseService if mentioned_account.local? LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name) - elsif mentioned_account.ostatus? && !@status.stream_entry.hidden? + elsif mentioned_account.ostatus? && !@status.stream_entry.hidden? && !@status.local_only? NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id) - elsif mentioned_account.activitypub? + elsif mentioned_account.activitypub? && !@status.local_only? ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url) end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index ff48d9c75..deaa0549e 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -21,8 +21,11 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: options[:visibility] || account.user&.setting_default_privacy) DistributionWorker.perform_async(reblog.id) - Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(reblog.id) + + unless reblogged_status.local_only? + Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) + end create_notification(reblog) bump_potential_friendship(account, reblog) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 747f209f3..6e4998e07 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -24,6 +24,7 @@ class RemoveStatusService < BaseService remove_from_hashtags remove_from_public remove_from_media if status.media_attachments.any? + remove_from_direct if status.direct_visibility? @status.destroy! else @@ -163,6 +164,13 @@ class RemoveStatusService < BaseService redis.publish('timeline:public:local:media', @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 lock_options { redis: Redis.current, key: "distribute:#{@status.id}" } end diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb index 93bae2fa8..92ee5e643 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 = (ENV['MAX_TOOT_CHARS'] || 500).to_i def validate(status) return unless status.local? && !status.reblog? diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb index 2c7bce674..16353066c 100644 --- a/app/validators/status_pin_validator.rb +++ b/app/validators/status_pin_validator.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class StatusPinValidator < ActiveModel::Validator + MAX_PINNED = (ENV['MAX_PINNED_TOOTS'] || 5).to_i + def validate(pin) pin.errors.add(:base, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog? pin.errors.add(:base, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id pin.errors.add(:base, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility) - pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count > 4 && pin.account.local? + pin.errors.add(:base, I18n.t('statuses.pin_errors.limit')) if pin.account.status_pins.count >= MAX_PINNED && pin.account.local? end end diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index b248ed1d2..f02a7906a 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -2,7 +2,6 @@ = site_hostname - content_for :header_tags do - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' = render partial: 'shared/og' .grid-3 diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 4ef9f9478..52fb0d946 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -24,8 +24,8 @@ %span.counter-label= t('accounts.following', count: account.following_count) .counter{ class: active_nav_class(account_followers_url(account)) } - = link_to account_followers_url(account), title: number_with_delimiter(account.followers_count) do - %span.counter-number= number_to_human account.followers_count, strip_insignificant_zeros: true + = link_to account_followers_url(account), title: hide_followers_count?(account) ? nil : number_with_delimiter(account.followers_count) do + %span.counter-number= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) %span.counter-label= t('accounts.followers', count: account.followers_count) .spacer .public-account-header__tabs__tabs__buttons @@ -39,5 +39,5 @@ %strong= number_to_human account.following_count, strip_insignificant_zeros: true = t('accounts.following', count: account.following_count) = link_to account_followers_url(account) do - %strong= number_to_human account.followers_count, strip_insignificant_zeros: true + %strong= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) = t('accounts.followers', count: account.followers_count) diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index d448e3862..76dbf4388 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -50,6 +50,8 @@ %li = feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview) %li + = feature_hint(link_to(t('admin.dashboard.keybase'), edit_admin_settings_path), @keybase_integration) + %li = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) .dashboard__widgets__versions diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 055d2fbd7..3a4963489 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('.title') diff --git a/app/views/admin/pending_accounts/index.html.haml b/app/views/admin/pending_accounts/index.html.haml index 7ce5b8213..171976e33 100644 --- a/app/views/admin/pending_accounts/index.html.haml +++ b/app/views/admin/pending_accounts/index.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('admin.pending_accounts.title', count: User.pending.count) -- content_for :header_tags do - = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' - = form_for(@form, url: batch_admin_pending_accounts_path) do |f| = hidden_field_tag :page, params[:page] || 1 diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml index 863dada9e..fc430e19e 100644 --- a/app/views/admin/reports/show.html.haml +++ b/app/views/admin/reports/show.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('admin.reports.report', id: @report.id) diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 1c2c00f10..a8c9f6a58 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -9,7 +9,7 @@ .fields-row .fields-row__column.fields-row__column-6.fields-group - = f.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false + = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last .fields-row__column.fields-row__column-6.fields-group = f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, label: t('admin.settings.registrations_mode.title'), include_blank: false, label_method: lambda { |mode| I18n.t("admin.settings.registrations_mode.modes.#{mode}") } @@ -66,6 +66,18 @@ .fields-group = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html') + .fields-group + = f.input :hide_followers_count, as: :boolean, wrapper: :with_label, label: t('admin.settings.hide_followers_count.title'), hint: t('admin.settings.hide_followers_count.desc_html') + + .fields-group + = f.input :enable_keybase, as: :boolean, wrapper: :with_label, label: t('admin.settings.enable_keybase.title'), hint: t('admin.settings.enable_keybase.desc_html') + + .fields-group + = f.input :show_reblogs_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_reblogs_in_public_timelines.title'), hint: t('admin.settings.show_reblogs_in_public_timelines.desc_html') + + .fields-group + = f.input :show_replies_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_replies_in_public_timelines.title'), hint: t('admin.settings.show_replies_in_public_timelines.desc_html') + %hr.spacer/ .fields-group diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml index dd3c79815..55926f3b3 100644 --- a/app/views/admin/statuses/index.html.haml +++ b/app/views/admin/statuses/index.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('admin.statuses.title') \- diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml index a8aa68cc4..6608a5dcb 100644 --- a/app/views/directories/index.html.haml +++ b/app/views/directories/index.html.haml @@ -29,7 +29,7 @@ = number_to_human account.statuses_count, strip_insignificant_zeros: true %small= t('accounts.posts', count: account.statuses_count).downcase %td.accounts-table__count.optional - = number_to_human account.followers_count, strip_insignificant_zeros: true + = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) %small= t('accounts.followers', count: account.followers_count).downcase %td.accounts-table__count - if account.last_status_at.present? diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 4c7fac0b6..6c5268b61 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -7,8 +7,6 @@ %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' - .app-holder#mastodon{ data: { props: Oj.dump(default_props) } } %noscript = image_pack_tag 'logo.svg', alt: 'Mastodon' diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml new file mode 100644 index 000000000..066d9de42 --- /dev/null +++ b/app/views/layouts/_theme.html.haml @@ -0,0 +1,13 @@ +- if theme + - if theme[:pack] != 'common' && theme[:common] + = render partial: 'layouts/theme', object: theme[:common] + - if theme[:pack] + = javascript_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", integrity: true, crossorigin: 'anonymous' + - if theme[:skin] + - if !theme[:flavour] || theme[:skin] == 'default' + = stylesheet_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", integrity: true, media: 'all' + - else + = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}" + - if theme[:preload] + - theme[:preload].each do |link| + %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 083f2fac7..a0cb7c4fe 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - - content_for :content do .admin-wrapper .sidebar-wrapper diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 3d63dc0f3..34c25a7d1 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -20,16 +20,22 @@ %title= content_for?(:page_title) ? safe_join([yield(:page_title).chomp.html_safe, title], ' - ') : 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' + = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous' + - if @theme + - if @theme[:supported_locales].include? I18n.locale.to_s + = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", integrity: true, crossorigin: 'anonymous' + - elsif @theme[:supported_locales].include? 'en' + = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous' = csrf_meta_tags + = yield :header_tags + + -# These must come after :header_tags to ensure our initial state has been defined. + = render partial: 'layouts/theme', object: @core + = render partial: 'layouts/theme', object: @theme + - if Setting.custom_css.present? = stylesheet_link_tag custom_css_path, media: 'all' - = yield :header_tags - %body{ class: body_classes } = content_for?(:content) ? yield(:content) : yield diff --git a/app/views/layouts/auth.html.haml b/app/views/layouts/auth.html.haml index aa1990972..fcbd29fe9 100644 --- a/app/views/layouts/auth.html.haml +++ b/app/views/layouts/auth.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - - content_for :content do .container-alt .logo-container diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index 0503dcdc1..33e3714f8 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -10,10 +10,14 @@ - if storage_host? %link{ rel: 'dns-prefetch', href: storage_host }/ - = stylesheet_pack_tag 'common', media: 'all' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' - = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' - = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' + = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous' + - if @theme + - if @theme[:supported_locales].include? I18n.locale.to_s + = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", integrity: true, crossorigin: 'anonymous' + - elsif @theme[:supported_locales].include? 'en' + = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous' + = render partial: 'layouts/theme', object: @core + = render partial: 'layouts/theme', object: @theme + %body.embed = yield diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index 25c85abf9..f8315afb5 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -5,10 +5,9 @@ %meta{ charset: 'utf-8' }/ %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' - = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' - = javascript_pack_tag 'error', integrity: true, crossorigin: 'anonymous' + = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous' + = render partial: 'layouts/theme', object: (@core || { pack: 'common' }) + = render partial: 'layouts/theme', object: (@theme || { pack: 'error', flavour: 'glitch', common: { pack: 'common', flavour: 'glitch', skin: 'default' } }) %body.error .dialog .dialog__illustration diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml index 4ffe3d79a..26fb697bb 100644 --- a/app/views/layouts/mailer.html.haml +++ b/app/views/layouts/mailer.html.haml @@ -6,7 +6,7 @@ %title/ - = stylesheet_pack_tag 'mailer' + = stylesheet_pack_tag 'core/mailer' %body{ dir: locale_direction } %table.email-table{ cellspacing: 0, cellpadding: 0 } %tbody diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml index 2ef49e413..b6e33ca91 100644 --- a/app/views/layouts/modal.html.haml +++ b/app/views/layouts/modal.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - - content_for :content do - if user_signed_in? && !@hide_header .account-header diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index d30b2a3ea..b4a21caf1 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - - content_for :content do .public-layout - unless @hide_navbar diff --git a/app/views/public_timelines/show.html.haml b/app/views/public_timelines/show.html.haml index 913d5d855..f80157c67 100644 --- a/app/views/public_timelines/show.html.haml +++ b/app/views/public_timelines/show.html.haml @@ -4,7 +4,6 @@ - content_for :header_tags do %meta{ name: 'robots', content: 'noindex' }/ %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) - = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous' .page-header %h1= t('about.see_whats_happening') diff --git a/app/views/relationships/show.html.haml b/app/views/relationships/show.html.haml index e6fff0ad6..1de4e373f 100644 --- a/app/views/relationships/show.html.haml +++ b/app/views/relationships/show.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('settings.relationships') -- content_for :header_tags do - = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' - .filters .filter-subset %strong= t 'relationships.relationship' diff --git a/app/views/settings/flavours/show.html.haml b/app/views/settings/flavours/show.html.haml new file mode 100644 index 000000000..c3f785aa0 --- /dev/null +++ b/app/views/settings/flavours/show.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t "flavours.#{@selected}.name", default: @selected + += simple_form_for current_user, url: settings_flavour_path(@selected), html: { method: :put } do |f| + = render 'shared/error_messages', object: current_user + + - Themes.instance.flavour(@selected)['screenshot'].each do |screen| + %img.flavour-screen{ src: full_pack_url("media/#{screen}") } + + .flavour-description + = t "flavours.#{@selected}.description", default: '' + + %hr/ + + - if Themes.instance.skins_for(@selected).length > 1 + .fields-group + = f.input :setting_skin, collection: Themes.instance.skins_for(@selected), label_method: lambda { |skin| I18n.t("skins.#{@selected}.#{skin}", default: skin) }, wrapper: :with_label, include_blank: false + + .actions + = f.button :button, t('generic.use_this'), type: :submit diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index d81ee61ad..a50f33517 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -38,17 +38,16 @@ .fields-group = f.input :setting_show_application, as: :boolean, wrapper: :with_label - %hr#settings_web/ + - unless Setting.hide_followers_count + .fields-group + = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label - .fields-row - .fields-group.fields-row__column.fields-row__column-6 - = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false, hint: false - .fields-group.fields-row__column.fields-row__column-6 - = f.input :setting_display_media, collection: ['default', 'show_all', 'hide_all'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| t("simple_form.hints.defaults.setting_display_media_#{item}") }, hint: false + %hr#settings_web/ .fields-group = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label + = f.input :setting_favourite_modal, as: :boolean, wrapper: :with_label = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label .fields-group @@ -56,6 +55,7 @@ .fields-group = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label + = f.input :setting_display_media, collection: ['default', 'show_all', 'hide_all'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_display_media_#{item}"), content_tag(:span, t("simple_form.hints.defaults.setting_display_media_#{item}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index eb232dc57..05cc93d69 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -6,8 +6,8 @@ .fields-row .fields-row__column.fields-group.fields-row__column-6 - = f.input :display_name, wrapper: :with_label, input_html: { maxlength: 30 }, hint: false - = f.input :note, wrapper: :with_label, input_html: { maxlength: 160 }, hint: false + = f.input :display_name, wrapper: :with_label, input_html: { maxlength: Account::MAX_DISPLAY_NAME_LENGTH }, hint: false + = f.input :note, wrapper: :with_label, input_html: { maxlength: Account::MAX_NOTE_LENGTH }, hint: false .fields-row .fields-row__column.fields-row__column-6 diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml index 44b6f145f..4c0390c42 100644 --- a/app/views/shares/show.html.haml +++ b/app/views/shares/show.html.haml @@ -1,5 +1,4 @@ - content_for :header_tags do %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) - = javascript_pack_tag 'share', integrity: true, crossorigin: 'anonymous' #mastodon-compose{ data: { props: Oj.dump(default_props) } } diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 0df7497e1..4df1a0cdf 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -24,7 +24,8 @@ %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }< %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)} %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) + .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< + = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - if status.preloadable_poll = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index cf4246822..1a9c58983 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -6,7 +6,6 @@ %link{ rel: 'alternate', type: 'application/rss+xml', href: tag_url(@tag, format: 'rss') }/ %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) - = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous' = render 'og' .page-header diff --git a/app/workers/activitypub/distribute_poll_update_worker.rb b/app/workers/activitypub/distribute_poll_update_worker.rb index 5eaca6fda..98b227111 100644 --- a/app/workers/activitypub/distribute_poll_update_worker.rb +++ b/app/workers/activitypub/distribute_poll_update_worker.rb @@ -9,7 +9,7 @@ class ActivityPub::DistributePollUpdateWorker @status = Status.find(status_id) @account = @status.account - return unless @status.preloadable_poll + return if @status.preloadable_poll.nil? || @status.local_only? ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| [payload, @account.id, inbox_url] diff --git a/config/application.rb b/config/application.rb index 74006f5fa..150fdce6c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -10,6 +10,7 @@ require_relative '../app/lib/exceptions' require_relative '../lib/paperclip/lazy_thumbnail' 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' require_relative '../lib/devise/ldap_authenticatable' diff --git a/config/environments/production.rb b/config/environments/production.rb index 70baa6ad1..7a07a8eb0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -95,10 +95,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', + 'Referrer-Policy' => 'same-origin', + 'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload', + 'X-Clacks-Overhead' => 'GNU Natalie Nguyen' + } config.x.otp_secret = ENV.fetch('OTP_SECRET') diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 1bcac154b..b2a621e85 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -59,4 +59,5 @@ ignore_unused: - 'errors.429' - 'admin.accounts.roles.*' - 'admin.action_logs.actions.*' + - 'themes.*' - 'statuses.attached.*' diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 59cfbba17..2fe1a33fa 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -2,29 +2,42 @@ # For further information see the following documentation # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy -base_host = Rails.configuration.x.web_domain -assets_host = Rails.configuration.action_controller.asset_host -assets_host ||= "http#{Rails.configuration.x.use_https ? 's' : ''}://#{base_host}" +if Rails.env.production? + assets_host = Rails.configuration.action_controller.asset_host || "https://#{ENV['WEB_DOMAIN'] || ENV['LOCAL_DOMAIN']}" + data_hosts = [assets_host] -Rails.application.config.content_security_policy do |p| - p.base_uri :none - p.default_src :none - p.frame_ancestors :none - p.font_src :self, assets_host - p.img_src :self, :https, :data, :blob, assets_host - p.style_src :self, :unsafe_inline, assets_host - p.media_src :self, :https, :data, assets_host - p.frame_src :self, :https - p.manifest_src :self, assets_host + if ENV['S3_ENABLED'] == 'true' + attachments_host = "https://#{ENV['S3_ALIAS_HOST'] || ENV['S3_CLOUDFRONT_HOST'] || ENV['S3_HOSTNAME'] || "s3-#{ENV['S3_REGION'] || 'us-east-1'}.amazonaws.com"}" + attachments_host = "https://#{Addressable::URI.parse(attachments_host).host}" + elsif ENV['SWIFT_ENABLED'] == 'true' + attachments_host = ENV['SWIFT_OBJECT_URL'] + attachments_host = "https://#{Addressable::URI.parse(attachments_host).host}" + else + attachments_host = nil + end - if Rails.env.development? - webpacker_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{Webpacker.dev_server.host_with_port}" } + data_hosts << attachments_host unless attachments_host.nil? - p.connect_src :self, :blob, assets_host, Rails.configuration.x.streaming_api_base_url, *webpacker_urls - p.script_src :self, :unsafe_inline, :unsafe_eval, assets_host - else - p.connect_src :self, :blob, assets_host, Rails.configuration.x.streaming_api_base_url - p.script_src :self, assets_host + if ENV['PAPERCLIP_ROOT_URL'] + url = Addressable::URI.parse(assets_host) + ENV['PAPERCLIP_ROOT_URL'] + data_hosts << "https://#{url.host}" + end + + data_hosts.uniq! + + Rails.application.config.content_security_policy do |p| + p.base_uri :none + p.default_src :none + p.frame_ancestors :none + p.script_src :self, assets_host + p.font_src :self, assets_host + p.img_src :self, :data, :blob, *data_hosts + p.style_src :self, :unsafe_inline, assets_host + p.media_src :self, :data, *data_hosts + p.frame_src :self, :https + p.worker_src :self, assets_host + p.connect_src :self, :blob, Rails.configuration.x.streaming_api_base_url, *data_hosts + p.manifest_src :self, assets_host end end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 55f8c9c91..bc782bc76 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -30,5 +30,9 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do 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/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 367eead6a..2a963b32b 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -58,6 +58,7 @@ Doorkeeper.configure do optional_scopes :write, :'write:accounts', :'write:blocks', + :'write:bookmarks', :'write:conversations', :'write:favourites', :'write:filters', @@ -71,6 +72,7 @@ Doorkeeper.configure do :read, :'read:accounts', :'read:blocks', + :'read:bookmarks', :'read:favourites', :'read:filters', :'read:follows', diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb new file mode 100644 index 000000000..04ed31646 --- /dev/null +++ b/config/initializers/locale.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names.{rb,yml}').to_s] +I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names', '*.{rb,yml}').to_s] +I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names.{rb,yml}').to_s] +I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names', '*.{rb,yml}').to_s] diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index f1fe03716..211b210d7 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -119,6 +119,7 @@ en: read: read all your account's data read:accounts: see accounts information read:blocks: see your blocks + read:bookmarks: see your bookmarks read:favourites: see your favourites read:filters: see your filters read:follows: see your follows @@ -131,6 +132,7 @@ en: write: modify all your account's data write:accounts: modify your profile write:blocks: block accounts and domains + write:bookmarks: bookmark statuses write:favourites: favourite statuses write:filters: create filters write:follows: follow people diff --git a/config/locales/en.yml b/config/locales/en.yml index 6d59411a5..5f87b34d6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -252,6 +252,7 @@ en: feature_timeline_preview: Timeline preview features: Features hidden_service: Federation with hidden services + keybase: Keybase integration open_reports: open reports recent_users: Recent users search: Full-text search @@ -398,9 +399,15 @@ en: custom_css: desc_html: Modify the look with CSS loaded on every page title: Custom CSS + enable_keybase: + desc_html: Allow your users to prove their identity via keybase + title: Enable keybase integration hero: desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to server thumbnail title: Hero image + hide_followers_count: + desc_html: Do not show followers count on user profiles + title: Hide followers count mascot: desc_html: Displayed on multiple pages. At least 293×205px recommended. When not set, falls back to default mascot title: Mascot image @@ -432,6 +439,12 @@ en: show_known_fediverse_at_about_page: desc_html: When toggled, it will show toots from all the known fediverse on preview. Otherwise it will only show local toots. title: Show known fediverse on timeline preview + show_reblogs_in_public_timelines: + desc_html: Show public boosts of public toots in local and public timelines. + title: Show boosts in public timelines + show_replies_in_public_timelines: + desc_html: In addition to public self-replies (threads), show public replies in local and public timelines. + title: Show replies in public timelines show_staff_badge: desc_html: Show a staff badge on a user page title: Show staff badge @@ -641,6 +654,7 @@ en: copy: Copy order_by: Order by save_changes: Save changes + use_this: Use this validation_errors: one: Something isn't quite right yet! Please review the error below other: Something isn't quite right yet! Please review %{count} errors below @@ -871,6 +885,7 @@ en: edit_profile: Edit profile export: Data export featured_tags: Featured hashtags + flavours: Flavours identity_proofs: Identity proofs import: Import import_and_export: Import and export diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 4087e299c..b834ff5f5 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -641,6 +641,7 @@ ja: copy: コピー order_by: 並び順 save_changes: 変更を保存 + use_this: これを使う validation_errors: one: エラーが発生しました! 以下のエラーを確認してください other: エラーが発生しました! 以下の%{count}個のエラーを確認してください @@ -870,6 +871,7 @@ ja: edit_profile: プロフィールを編集 export: データのエクスポート featured_tags: 注目のハッシュタグ + flavours: フレーバー identity_proofs: Identity proofs import: データのインポート import_and_export: インポート・エクスポート diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 1b9bb614c..d4e07f6e8 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -648,6 +648,7 @@ pl: copy: Kopiuj order_by: Uporządkuj według save_changes: Zapisz zmiany + use_this: Użyj tego validation_errors: few: Coś jest wciąż nie tak! Przejrzyj %{count} poniższe błędy many: Coś jest wciąż nie tak! Przejrzyj %{count} poniższych błędów @@ -883,6 +884,7 @@ pl: edit_profile: Edytuj profil export: Eksportowanie danych featured_tags: Wyróżnione hashtagi + flavours: Odmiany identity_proofs: Dowody tożsamości import: Importowanie danych migrate: Migracja konta diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 1a43e19e2..ba0e403e4 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -34,7 +34,7 @@ en: setting_hide_network: Who you follow and who follows you will not be shown on your profile setting_noindex: Affects your public profile and status pages setting_show_application: The application you use to toot will be displayed in the detailed view of your toots - setting_theme: Affects how Mastodon looks when you're logged in from any device. + setting_skin: Reskins the selected Mastodon flavour username: Your username will be unique on %{domain} whole_word: When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word featured_tag: @@ -102,12 +102,14 @@ en: setting_display_media_hide_all: Hide all setting_display_media_show_all: Show all setting_expand_spoilers: Always expand toots marked with content warnings + setting_favourite_modal: Show confirmation dialog before favouriting (applies to Glitch flavour only) + setting_hide_followers_count: Hide your followers count setting_hide_network: Hide your network setting_noindex: Opt-out of search engine indexing setting_reduce_motion: Reduce motion in animations setting_show_application: Disclose application used to send toots + setting_skin: Skin setting_system_font_ui: Use system's default font - setting_theme: Site theme setting_unfollow_modal: Show confirmation dialog before unfollowing someone severity: Severity type: Import type diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 05933e016..c82f1943f 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -102,6 +102,7 @@ ja: setting_display_media_hide_all: 非表示 setting_display_media_show_all: 表示 setting_expand_spoilers: 閲覧注意としてマークされたトゥートを常に展開する + setting_favourite_modal: お気に入りをする前に確認ダイアログを表示する setting_hide_network: 繋がりを隠す setting_noindex: 検索エンジンによるインデックスを拒否する setting_reduce_motion: アニメーションの動きを減らす diff --git a/config/locales/simple_form.pl.yml b/config/locales/simple_form.pl.yml index e1cbee91c..0da8c1063 100644 --- a/config/locales/simple_form.pl.yml +++ b/config/locales/simple_form.pl.yml @@ -34,7 +34,7 @@ pl: setting_hide_network: Informacje o tym, kto Cię śledzi i kogo śledzisz nie będą widoczne setting_noindex: Wpływa na widoczność strony profilu i Twoich wpisów setting_show_application: W informacjach o wpisie będzie widoczna informacja o aplikacji, z której został wysłany - setting_theme: Zmienia wygląd Mastodona po zalogowaniu z dowolnego urządzenia. + setting_skin: Zmienia wygląd używanej odmiany Mastodona username: Twoja nazwa użytkownika będzie niepowtarzalna na %{domain} whole_word: Jeśli słowo lub fraza składa się jedynie z liter lub cyfr, filtr będzie zastosowany tylko do pełnych wystąpień featured_tag: @@ -101,12 +101,13 @@ pl: setting_display_media_hide_all: Ukryj wszystko setting_display_media_show_all: Pokaż wszystko setting_expand_spoilers: Zawsze rozwijaj wpisy oznaczone ostrzeżeniem o zawartości + setting_favourite_modal: Pytaj o potwierdzenie przed dodaniem do ulubionych setting_hide_network: Ukryj swoją sieć setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych setting_reduce_motion: Ogranicz ruch w animacjach setting_show_application: Informuj o aplikacji z której wysłano wpisy + setting_skin: Motyw setting_system_font_ui: Używaj domyślnej czcionki systemu - setting_theme: Motyw strony setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia severity: Priorytet type: Importowane dane diff --git a/config/navigation.rb b/config/navigation.rb index c2a8e45ce..16a99731a 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -11,6 +11,13 @@ SimpleNavigation::Configuration.run do |navigation| end n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, highlights_on: %r{/settings/preferences|/settings/notifications} + + n.item :flavours, safe_join([fa_icon('paint-brush fw'), t('settings.flavours')]), settings_flavours_url do |flavours| + Themes.instance.flavours.each do |flavour| + flavours.item flavour.to_sym, safe_join([fa_icon('star fw'), t("flavours.#{flavour}.name", default: flavour)]), settings_flavour_url(flavour) + end + end + n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters} diff --git a/config/routes.rb b/config/routes.rb index 34d0081e7..5614a7cdc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,7 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [:show, :update] + resource :preferences, only: [:show, :update] resource :notifications, only: [:show, :update] resource :import, only: [:show, :create] @@ -116,6 +117,8 @@ Rails.application.routes.draw do end end + resources :flavours, only: [:index, :show, :update], param: :flavour + resource :delete, only: [:show, :destroy] resource :migration, only: [:show, :update] @@ -275,6 +278,9 @@ Rails.application.routes.draw do resource :favourite, only: :create post :unfavourite, to: 'favourites#destroy' + resource :bookmark, only: :create + post :unbookmark, to: 'bookmarks#destroy' + resource :mute, only: :create post :unmute, to: 'mutes#destroy' @@ -313,8 +319,13 @@ 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 :bookmarks, only: [:index] resources :reports, only: [:create] resources :filters, only: [:index, :create, :show, :update, :destroy] resources :endorsements, only: [:index] @@ -339,10 +350,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 # Deprecated + delete :destroy_multiple end member do diff --git a/config/settings.yml b/config/settings.yml index 63f7c3380..c3aeab551 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -2,7 +2,7 @@ # important settings can be changed from the admin interface. defaults: &defaults - site_title: Mastodon + site_title: 'dev.glitch.social' site_short_description: '' site_description: '' site_extended_description: '' @@ -13,23 +13,27 @@ defaults: &defaults profile_directory: true closed_registrations_message: '' open_deletion: true + timeline_preview: false min_invite_role: 'admin' - timeline_preview: true show_staff_badge: true default_sensitive: false hide_network: false unfollow_modal: false boost_modal: false + favourite_modal: false delete_modal: true auto_play_gif: false display_media: 'default' expand_spoilers: false preview_sensitive_media: false reduce_motion: false - show_application: true + show_application: false system_font_ui: false noindex: false - theme: 'default' + hide_followers_count: false + enable_keybase: true + flavour: 'glitch' + skin: 'default' aggregate_reblogs: true notification_emails: follow: false @@ -58,6 +62,8 @@ defaults: &defaults activity_api_enabled: true peers_api_enabled: true show_known_fediverse_at_about_page: true + show_reblogs_in_public_timelines: false + show_replies_in_public_timelines: false development: <<: *defaults diff --git a/config/themes.yml b/config/themes.yml deleted file mode 100644 index 9c21c9459..000000000 --- a/config/themes.yml +++ /dev/null @@ -1,3 +0,0 @@ -default: styles/application.scss -contrast: styles/contrast.scss -mastodon-light: styles/mastodon-light.scss diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js index 80a094c72..926af9b39 100644 --- a/config/webpack/configuration.js +++ b/config/webpack/configuration.js @@ -1,15 +1,61 @@ // Common configuration for webpacker loaded from config/webpacker.yml -const { resolve } = require('path'); +const { basename, dirname, extname, join, resolve } = require('path'); const { env } = require('process'); const { safeLoad } = require('js-yaml'); -const { readFileSync } = require('fs'); +const { lstatSync, readFileSync } = require('fs'); +const glob = require('glob'); const configPath = resolve('config', 'webpacker.yml'); const settings = safeLoad(readFileSync(configPath), 'utf8')[env.RAILS_ENV || env.NODE_ENV]; +const flavourFiles = glob.sync('app/javascript/flavours/*/theme.yml'); +const skinFiles = glob.sync('app/javascript/skins/*/*'); +const flavours = {}; -const themePath = resolve('config', 'themes.yml'); -const themes = safeLoad(readFileSync(themePath), 'utf8'); +const core = function () { + const coreFile = resolve('app', 'javascript', 'core', 'theme.yml'); + const data = safeLoad(readFileSync(coreFile), 'utf8'); + if (!data.pack_directory) { + data.pack_directory = dirname(coreFile); + } + return data.pack ? data : {}; +}(); + +for (let i = 0; i < flavourFiles.length; i++) { + const flavourFile = flavourFiles[i]; + const data = safeLoad(readFileSync(flavourFile), 'utf8'); + data.name = basename(dirname(flavourFile)); + data.skin = {}; + if (!data.pack_directory) { + data.pack_directory = dirname(flavourFile); + } + if (data.locales) { + data.locales = join(dirname(flavourFile), data.locales); + } + if (data.pack && typeof data.pack === 'object') { + flavours[data.name] = data; + } +} + +for (let i = 0; i < skinFiles.length; i++) { + const skinFile = skinFiles[i]; + let skin = basename(skinFile); + const name = basename(dirname(skinFile)); + if (!flavours[name]) { + continue; + } + const data = flavours[name].skin; + if (lstatSync(skinFile).isDirectory()) { + data[skin] = {}; + const skinPacks = glob.sync(join(skinFile, '*.{css,scss}')); + for (let j = 0; j < skinPacks.length; j++) { + const pack = skinPacks[j]; + data[skin][basename(pack, extname(pack))] = pack; + } + } else if ((skin = skin.match(/^(.*)\.s?css$/i))) { + data[skin[1]] = { common: skinFile }; + } +} function removeOuterSlashes(string) { return string.replace(/^\/*/, '').replace(/\/*$/, ''); @@ -31,7 +77,8 @@ const output = { module.exports = { settings, - themes, + core, + flavours, env: { CDN_HOST: env.CDN_HOST, NODE_ENV: env.NODE_ENV, diff --git a/config/webpack/generateLocalePacks.js b/config/webpack/generateLocalePacks.js index b71cf2ade..09fba4a18 100644 --- a/config/webpack/generateLocalePacks.js +++ b/config/webpack/generateLocalePacks.js @@ -1,52 +1,66 @@ +// A message from upstream: +// ======================== // To avoid adding a lot of boilerplate, locale packs are // automatically generated here. These are written into the tmp/ // directory and then used to generate locale_en.js, locale_fr.js, etc. -const fs = require('fs'); -const path = require('path'); +// Glitch note: +// ============ +// This code has been entirely rewritten to support glitch flavours. +// However, the underlying process is exactly the same. + +const { existsSync, readdirSync, writeFileSync } = require('fs'); +const { join, resolve } = require('path'); const rimraf = require('rimraf'); const mkdirp = require('mkdirp'); +const { flavours } = require('./configuration.js'); + +module.exports = Object.keys(flavours).reduce(function (map, entry) { + const flavour = flavours[entry]; + if (!flavour.locales) { + return map; + } + const locales = readdirSync(flavour.locales).filter( + filename => /\.js(?:on)?$/.test(filename) && !/defaultMessages|whitelist|index/.test(filename) + ); + const outPath = resolve('tmp', 'locales', entry); -const localesJsonPath = path.join(__dirname, '../../app/javascript/mastodon/locales'); -const locales = fs.readdirSync(localesJsonPath).filter(filename => { - return /\.json$/.test(filename) && - !/defaultMessages/.test(filename) && - !/whitelist/.test(filename); -}).map(filename => filename.replace(/\.json$/, '')); - -const outPath = path.join(__dirname, '../../tmp/packs'); - -rimraf.sync(outPath); -mkdirp.sync(outPath); - -const outPaths = []; - -locales.forEach(locale => { - const localePath = path.join(outPath, `locale_${locale}.js`); - const baseLocale = locale.split('-')[0]; // e.g. 'zh-TW' -> 'zh' - const localeDataPath = [ - // first try react-intl - `../../node_modules/react-intl/locale-data/${baseLocale}.js`, - // then check locales/locale-data - `../../app/javascript/mastodon/locales/locale-data/${baseLocale}.js`, - // fall back to English (this is what react-intl does anyway) - '../../node_modules/react-intl/locale-data/en.js', - ].filter(filename => fs.existsSync(path.join(outPath, filename))) - .map(filename => filename.replace(/..\/..\/node_modules\//, ''))[0]; - - const localeContent = `// -// locale_${locale}.js + rimraf.sync(outPath); + mkdirp.sync(outPath); + + locales.forEach(function (locale) { + const localeName = locale.replace(/\.js(?:on)?$/, ''); + const localePath = join(outPath, `${localeName}.js`); + const baseLocale = localeName.split('-')[0]; // e.g. 'zh-TW' -> 'zh' + const localeDataPath = [ + // first try react-intl + `node_modules/react-intl/locale-data/${baseLocale}.js`, + // then check locales/locale-data + `app/javascript/locales/locale-data/${baseLocale}.js`, + // fall back to English (this is what react-intl does anyway) + 'node_modules/react-intl/locale-data/en.js', + ].filter( + filename => existsSync(filename) + ).map( + filename => filename.replace(/(?:node_modules|app\/javascript)\//, '') + )[0]; + const localeContent = `// +// locales/${entry}/${localeName}.js // automatically generated by generateLocalePacks.js // -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}); -`; - fs.writeFileSync(localePath, localeContent, 'utf8'); - outPaths.push(localePath); -}); -module.exports = outPaths; +import messages from '../../../${flavour.locales}/${locale.replace(/\.js$/, '')}'; +import localeData from '${localeDataPath}'; +import { setLocale } from 'locales'; +setLocale({ + localeData, + messages, +}); +`; + writeFileSync(localePath, localeContent, 'utf8'); + map[`locales/${entry}/${localeName}`] = localePath; + }); + return map; +}, {}); diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js index 2fc245c43..4d25748ee 100644 --- a/config/webpack/rules/babel.js +++ b/config/webpack/rules/babel.js @@ -12,6 +12,7 @@ module.exports = { { loader: 'babel-loader', options: { + sourceRoot: 'app/javascript', cacheDirectory: join(settings.cache_path, 'babel-loader'), cacheCompression: env.NODE_ENV === 'production', compact: env.NODE_ENV === 'production', diff --git a/config/webpack/rules/css.js b/config/webpack/rules/css.js index 27905a617..9553cb55b 100644 --- a/config/webpack/rules/css.js +++ b/config/webpack/rules/css.js @@ -21,6 +21,7 @@ module.exports = { { loader: 'sass-loader', options: { + includePaths: ['app/javascript'], fiber: require('fibers'), implementation: require('sass'), sourceMap: true, diff --git a/config/webpack/shared.js b/config/webpack/shared.js index cb4e5a85f..b6e2537b1 100644 --- a/config/webpack/shared.js +++ b/config/webpack/shared.js @@ -5,33 +5,56 @@ const { basename, dirname, join, relative, resolve } = require('path'); const { sync } = require('glob'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const AssetsManifestPlugin = require('webpack-assets-manifest'); -const extname = require('path-complete-extname'); -const { env, settings, themes, output } = require('./configuration'); +const { env, settings, core, flavours, output } = require('./configuration.js'); const rules = require('./rules'); -const localePackPaths = require('./generateLocalePacks'); +const localePacks = require('./generateLocalePacks'); + +function reducePacks (data, into = {}) { + if (!data.pack) { + return into; + } + Object.keys(data.pack).reduce((map, entry) => { + const pack = data.pack[entry]; + if (!pack) { + return map; + } + const packFile = typeof pack === 'string' ? pack : pack.filename; + if (packFile) { + map[data.name ? `flavours/${data.name}/${entry}` : `core/${entry}`] = resolve(data.pack_directory, packFile); + } + return map; + }, into); + if (data.name) { + Object.keys(data.skin).reduce((map, entry) => { + const skin = data.skin[entry]; + const skinName = entry; + if (!skin) { + return map; + } + Object.keys(skin).reduce((map, entry) => { + const packFile = skin[entry]; + if (!packFile) { + return map; + } + map[`skins/${data.name}/${skinName}/${entry}`] = resolve(packFile); + return map; + }, into); + return map; + }, into); + } + return into; +} + +const entries = Object.assign( + { locales: resolve('app', 'javascript', 'locales') }, + localePacks, + reducePacks(core), + Object.keys(flavours).reduce((map, entry) => reducePacks(flavours[entry], map), {}) +); -const extensionGlob = `**/*{${settings.extensions.join(',')}}*`; -const entryPath = join(settings.source_path, settings.source_entry_path); -const packPaths = sync(join(entryPath, extensionGlob)); module.exports = { - entry: Object.assign( - packPaths.reduce((map, entry) => { - const localMap = map; - const namespace = relative(join(entryPath), dirname(entry)); - localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry); - return localMap; - }, {}), - localePackPaths.reduce((map, entry) => { - const localMap = map; - 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; - }, {}) - ), + entry: entries, output: { filename: 'js/[name]-[chunkhash].js', @@ -43,7 +66,7 @@ module.exports = { optimization: { runtimeChunk: { - name: 'common', + name: 'locales', }, splitChunks: { cacheGroups: { @@ -51,7 +74,9 @@ module.exports = { vendors: false, common: { name: 'common', - chunks: 'all', + chunks (chunk) { + return !(chunk.name in entries); + }, minChunks: 2, minSize: 0, test: /^(?!.*[\\\/]node_modules[\\\/]react-intl[\\\/]).+$/, diff --git a/config/webpacker.yml b/config/webpacker.yml index 4ad78a190..9accd6152 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -26,6 +26,7 @@ default: &default - .tiff - .ico - .svg + - .gif - .eot - .otf - .ttf diff --git a/db/migrate/20170716191202_add_hide_notifications_to_mute.rb b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb index a498396b7..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/20171210213213_add_local_only_flag_to_statuses.rb b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb new file mode 100644 index 000000000..af1e29d6a --- /dev/null +++ b/db/migrate/20171210213213_add_local_only_flag_to_statuses.rb @@ -0,0 +1,5 @@ +class AddLocalOnlyFlagToStatuses < ActiveRecord::Migration[5.1] + def change + add_column :statuses, :local_only, :boolean + end +end diff --git a/db/migrate/20180410220657_create_bookmarks.rb b/db/migrate/20180410220657_create_bookmarks.rb new file mode 100644 index 000000000..208da452b --- /dev/null +++ b/db/migrate/20180410220657_create_bookmarks.rb @@ -0,0 +1,14 @@ +class CreateBookmarks < ActiveRecord::Migration[5.1] + def change + create_table :bookmarks do |t| + t.references :account, null: false + t.references :status, null: false + + t.timestamps + end + + add_foreign_key :bookmarks, :accounts, column: :account_id, on_delete: :cascade + add_foreign_key :bookmarks, :statuses, column: :status_id, on_delete: :cascade + add_index :bookmarks, [:account_id, :status_id], unique: true + end +end diff --git a/db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb b/db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb new file mode 100644 index 000000000..cd97d0f20 --- /dev/null +++ b/db/migrate/20180604000556_add_apply_to_mentions_flag_to_keyword_mutes.rb @@ -0,0 +1,17 @@ +require 'mastodon/migration_helpers' + +class AddApplyToMentionsFlagToKeywordMutes < ActiveRecord::Migration[5.2] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default :glitch_keyword_mutes, :apply_to_mentions, :boolean, allow_null: false, default: true + end + end + + def down + remove_column :glitch_keyword_mutes, :apply_to_mentions + end +end diff --git a/db/migrate/20180707193142_migrate_filters.rb b/db/migrate/20180707193142_migrate_filters.rb new file mode 100644 index 000000000..10b814c0f --- /dev/null +++ b/db/migrate/20180707193142_migrate_filters.rb @@ -0,0 +1,54 @@ +class MigrateFilters < ActiveRecord::Migration[5.2] + class GlitchKeywordMute < ApplicationRecord + # Dummy class, as we removed Glitch::KeywordMute + belongs_to :account, required: true + validates_presence_of :keyword + end + + class CustomFilter < ApplicationRecord + # Dummy class, in case CustomFilter gets altered in the future + belongs_to :account + validates :phrase, :context, presence: true + + before_validation :clean_up_contexts + + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).map(&:presence).compact + end + end + + disable_ddl_transaction! + + def up + GlitchKeywordMute.find_each do |filter| + filter.account.custom_filters.create!( + phrase: filter.keyword, + context: filter.apply_to_mentions ? %w(home public notifications) : %w(home public), + whole_word: filter.whole_word, + irreversible: true) + end + end + + def down + unless table_exists? :glitch_keyword_mutes + create_table :glitch_keyword_mutes do |t| + t.references :account, null: false + t.string :keyword, null: false + t.boolean :whole_word, default: true, null: false + t.boolean :apply_to_mentions, default: true, null: false + t.timestamps + end + + add_foreign_key :glitch_keyword_mutes, :accounts, on_delete: :cascade + end + + CustomFilter.where(irreversible: true).find_each do |filter| + GlitchKeywordMute.where(account: filter.account).create!( + keyword: filter.phrase, + whole_word: filter.whole_word, + apply_to_mentions: filter.context.include?('notifications')) + end + end +end diff --git a/db/migrate/20181127165847_add_show_replies_to_lists.rb b/db/migrate/20181127165847_add_show_replies_to_lists.rb new file mode 100644 index 000000000..f68c98daf --- /dev/null +++ b/db/migrate/20181127165847_add_show_replies_to_lists.rb @@ -0,0 +1,23 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddShowRepliesToLists < ActiveRecord::Migration[5.2] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default( + :lists, + :replies_policy, + :integer, + allow_null: false, + default: 0 + ) + end + end + + def down + remove_column :lists, :replies_policy + end +end diff --git a/db/post_migrate/20180813160548_post_migrate_filters.rb b/db/post_migrate/20180813160548_post_migrate_filters.rb new file mode 100644 index 000000000..588548c1d --- /dev/null +++ b/db/post_migrate/20180813160548_post_migrate_filters.rb @@ -0,0 +1,11 @@ +class PostMigrateFilters < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + drop_table :glitch_keyword_mutes if table_exists? :glitch_keyword_mutes + end + + def down + end +end + diff --git a/db/schema.rb b/db/schema.rb index 4dcbea4e7..43d589f3a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -195,6 +195,16 @@ ActiveRecord::Schema.define(version: 2019_05_09_164208) do t.index ["target_account_id"], name: "index_blocks_on_target_account_id" end + create_table "bookmarks", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "status_id"], name: "index_bookmarks_on_account_id_and_status_id", unique: true + t.index ["account_id"], name: "index_bookmarks_on_account_id" + t.index ["status_id"], name: "index_bookmarks_on_status_id" + end + create_table "conversation_mutes", force: :cascade do |t| t.bigint "conversation_id", null: false t.bigint "account_id", null: false @@ -344,6 +354,7 @@ ActiveRecord::Schema.define(version: 2019_05_09_164208) do t.string "title", default: "", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "replies_policy", default: 0, null: false t.index ["account_id"], name: "index_lists_on_account_id" end @@ -624,6 +635,7 @@ ActiveRecord::Schema.define(version: 2019_05_09_164208) do t.bigint "account_id", null: false t.bigint "application_id" t.bigint "in_reply_to_account_id" + t.boolean "local_only" t.bigint "poll_id" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc } t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" @@ -769,6 +781,8 @@ ActiveRecord::Schema.define(version: 2019_05_09_164208) do add_foreign_key "backups", "users", on_delete: :nullify add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade + add_foreign_key "bookmarks", "accounts", on_delete: :cascade + add_foreign_key "bookmarks", "statuses", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 91f45e45d..b2eec94e6 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -28,12 +28,16 @@ module Mastodon [major, minor, patch, pre].compact end + def suffix + '+glitch' + end + def to_s - [to_a.join('.'), flags].join + [to_a.join('.'), flags, suffix].join end def repository - ENV.fetch('GITHUB_REPOSITORY') { 'tootsuite/mastodon' } + ENV.fetch('GITHUB_REPOSITORY') { 'glitch-soc/mastodon' } end def source_base_url diff --git a/lib/paperclip/audio_transcoder.rb b/lib/paperclip/audio_transcoder.rb new file mode 100644 index 000000000..323ec7bfe --- /dev/null +++ b/lib/paperclip/audio_transcoder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Paperclip + class AudioTranscoder < Paperclip::Processor + def make + max_aud_len = (ENV['MAX_AUDIO_LENGTH'] || 60.0).to_f + + 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] > max_aud_len + raise Mastodon::ValidationError, "Audio uploads must be less than #{max_aud_len} 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/lib/tasks/assets.rake b/lib/tasks/assets.rake index b642510a1..5931aae61 100644 --- a/lib/tasks/assets.rake +++ b/lib/tasks/assets.rake @@ -1,13 +1,19 @@ # frozen_string_literal: true -def render_static_page(action, dest:, **opts) - html = ApplicationController.render(action, opts) - File.write(dest, html) -end - namespace :assets do desc 'Generate static pages' task generate_static_pages: :environment do + class StaticApplicationController < ApplicationController + def current_user + nil + end + end + + def render_static_page(action, dest:, **opts) + html = StaticApplicationController.render(action, opts) + File.write(dest, html) + end + render_static_page 'errors/500', layout: 'error', dest: Rails.root.join('public', 'assets', '500.html') end end diff --git a/lib/tasks/glitchsoc.rake b/lib/tasks/glitchsoc.rake new file mode 100644 index 000000000..79e864648 --- /dev/null +++ b/lib/tasks/glitchsoc.rake @@ -0,0 +1,8 @@ +namespace :glitchsoc do + desc 'Backfill local-only flag on statuses table' + task backfill_local_only: :environment do + Status.local.where(local_only: nil).find_each do |st| + ActiveRecord::Base.logger.silence { st.update_attribute(:local_only, st.marked_local_only?) } + end + end +end diff --git a/package.json b/package.json index 67396ccc3..8262c73aa 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "<rootDir>/config/", "<rootDir>/log/", "<rootDir>/public/", - "<rootDir>/tmp/" + "<rootDir>/tmp/", + "<rootDir>/app/javascript/themes/" ], "setupFiles": [ "raf/polyfill" @@ -70,6 +71,7 @@ "@babel/preset-react": "^7.0.0", "@babel/runtime": "^7.3.4", "array-includes": "^3.0.3", + "atrament": "^0.2.3", "autoprefixer": "^9.4.10", "axios": "^0.18.0", "babel-loader": "^8.0.5", @@ -91,6 +93,7 @@ "escape-html": "^1.0.3", "exif-js": "^2.3.0", "express": "^4.16.4", + "favico.js": "^0.3.10", "fibers": "^3.1.1", "file-loader": "^3.0.1", "font-awesome": "^4.7.0", 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/oops.png b/public/oops.png index 1ac779f25..0a48cbf94 100644 --- a/public/oops.png +++ b/public/oops.png 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 727669886..e9466e4ed 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -61,7 +61,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/bookmarks_controller_spec.rb b/spec/controllers/api/v1/bookmarks_controller_spec.rb new file mode 100644 index 000000000..79601b6e6 --- /dev/null +++ b/spec/controllers/api/v1/bookmarks_controller_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +RSpec.describe Api::V1::BookmarksController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:bookmarks') } + + describe 'GET #index' do + context 'without token' do + it 'returns http unauthorized' do + get :index + expect(response).to have_http_status :unauthorized + end + end + + context 'with token' do + context 'without read scope' do + before do + allow(controller).to receive(:doorkeeper_token) do + Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: '') + end + end + + it 'returns http forbidden' do + get :index + expect(response).to have_http_status :forbidden + end + end + + context 'without valid resource owner' do + before do + token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') + user.destroy! + + allow(controller).to receive(:doorkeeper_token) { token } + end + + it 'returns http unprocessable entity' do + get :index + expect(response).to have_http_status :unprocessable_entity + end + end + + context 'with read scope and valid resource owner' do + before do + allow(controller).to receive(:doorkeeper_token) do + Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') + end + end + + it 'shows bookmarks owned by the user' do + bookmarked_by_user = Fabricate(:bookmark, account: user.account) + bookmarked_by_others = Fabricate(:bookmark) + + get :index + + expect(assigns(:statuses)).to match_array [bookmarked_by_user.status] + end + + it 'adds pagination headers if necessary' do + bookmark = Fabricate(:bookmark, account: user.account) + + get :index, params: { limit: 1 } + + expect(response.headers['Link'].find_link(['rel', 'next']).href).to eq "http://test.host/api/v1/bookmarks?limit=1&max_id=#{bookmark.id}" + expect(response.headers['Link'].find_link(['rel', 'prev']).href).to eq "http://test.host/api/v1/bookmarks?limit=1&since_id=#{bookmark.id}" + end + + it 'does not add pagination headers if not necessary' do + get :index + + expect(response.headers['Link']).to eq nil + end + end + end + end +end diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb index a2b814a69..95ec17d6f 100644 --- a/spec/controllers/api/v1/mutes_controller_spec.rb +++ b/spec/controllers/api/v1/mutes_controller_spec.rb @@ -60,4 +60,25 @@ RSpec.describe Api::V1::MutesController, type: :controller do end end end + + describe 'GET #details' do + before do + Fabricate(:mute, account: user.account, hide_notifications: false) + 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/api/v1/statuses/bookmarks_controller_spec.rb b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb new file mode 100644 index 000000000..b79853718 --- /dev/null +++ b/spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::BookmarksController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:bookmarks', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + post :create, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the bookmarked attribute' do + expect(user.account.bookmarked?(status)).to be true + end + + it 'return json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id.to_s + expect(hash_body[:bookmarked]).to be true + end + end + + describe 'POST #destroy' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Bookmark.find_or_create_by!(account: user.account, status: status) + post :destroy, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the bookmarked attribute' do + expect(user.account.bookmarked?(status)).to be false + end + end + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 33cc71087..ea443b80c 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -92,36 +92,40 @@ describe ApplicationController, type: :controller do end end - describe 'helper_method :current_theme' do - it 'returns "default" when theme wasn\'t changed in admin settings' do - allow(Setting).to receive(:default_settings).and_return({ 'theme' => 'default' }) + describe 'helper_method :current_flavour' do + it 'returns "glitch" when theme wasn\'t changed in admin settings' do + allow(Setting).to receive(:default_settings).and_return({'skin' => 'default'}) + allow(Setting).to receive(:default_settings).and_return({'flavour' => 'glitch'}) - expect(controller.view_context.current_theme).to eq 'default' + expect(controller.view_context.current_flavour).to eq 'glitch' end - it 'returns instances\'s theme when user is not signed in' do - allow(Setting).to receive(:[]).with('theme').and_return 'contrast' + it 'returns instances\'s flavour when user is not signed in' do + allow(Setting).to receive(:[]).with('skin').and_return 'default' + allow(Setting).to receive(:[]).with('flavour').and_return 'vanilla' - expect(controller.view_context.current_theme).to eq 'contrast' + expect(controller.view_context.current_flavour).to eq 'vanilla' end - it 'returns instances\'s default theme when user didn\'t set theme' do + it 'returns instances\'s default flavour when user didn\'t set theme' do current_user = Fabricate(:user) sign_in current_user - allow(Setting).to receive(:[]).with('theme').and_return 'contrast' + allow(Setting).to receive(:[]).with('skin').and_return 'default' + allow(Setting).to receive(:[]).with('flavour').and_return 'vanilla' - expect(controller.view_context.current_theme).to eq 'contrast' + expect(controller.view_context.current_flavour).to eq 'vanilla' end - it 'returns user\'s theme when it is set' do + it 'returns user\'s flavour when it is set' do current_user = Fabricate(:user) - current_user.settings['theme'] = 'mastodon-light' + current_user.settings['flavour'] = 'glitch' sign_in current_user - allow(Setting).to receive(:[]).with('theme').and_return 'contrast' + allow(Setting).to receive(:[]).with('skin').and_return 'default' + allow(Setting).to receive(:[]).with('flavour').and_return 'vanilla' - expect(controller.view_context.current_theme).to eq 'mastodon-light' + expect(controller.view_context.current_flavour).to eq 'glitch' end end diff --git a/spec/controllers/settings/flavours_controller_spec.rb b/spec/controllers/settings/flavours_controller_spec.rb new file mode 100644 index 000000000..f89bde1f9 --- /dev/null +++ b/spec/controllers/settings/flavours_controller_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Settings::FlavoursController, type: :controller do + let(:user) { Fabricate(:user) } + + before do + sign_in user, scope: :user + end + + describe 'PUT #update' do + describe 'without a user[setting_skin] parameter' do + it 'sets the selected flavour' do + put :update, params: { flavour: 'schnozzberry' } + + user.reload + + expect(user.setting_flavour).to eq 'schnozzberry' + end + end + + describe 'with a user[setting_skin] parameter' do + before do + put :update, params: { flavour: 'schnozzberry', user: { setting_skin: 'wallpaper' } } + + user.reload + end + + it 'sets the selected flavour' do + expect(user.setting_flavour).to eq 'schnozzberry' + end + + it 'sets the selected skin' do + expect(user.setting_skin).to eq 'wallpaper' + end + end + end +end diff --git a/spec/fabricators/bookmark_fabricator.rb b/spec/fabricators/bookmark_fabricator.rb new file mode 100644 index 000000000..12cbc5bfa --- /dev/null +++ b/spec/fabricators/bookmark_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:bookmark) do + account + status +end 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 c506cd87f..df92094d1 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -119,6 +119,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, text: '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) @@ -284,14 +291,109 @@ RSpec.describe FeedManager do end describe '#push_to_list' do + let(:owner) { Fabricate(:account, username: 'owner') } + let(:alice) { Fabricate(:account, username: 'alice') } + let(:bob) { Fabricate(:account, username: 'bob') } + let(:eve) { Fabricate(:account, username: 'eve') } + let(:list) { Fabricate(:list, account: owner) } + + before do + owner.follow!(alice) + owner.follow!(bob) + owner.follow!(eve) + + list.accounts << alice + list.accounts << bob + end + it "does not push when the given status's reblog is already inserted" do - list = Fabricate(:list) reblog = Fabricate(:status) status = Fabricate(:status, reblog: reblog) FeedManager.instance.push_to_list(list, status) expect(FeedManager.instance.push_to_list(list, reblog)).to eq false end + + context 'when replies policy is set to no replies' do + before do + list.replies_policy = :no_replies + end + + it 'pushes statuses that are not replies' do + status = Fabricate(:status, text: 'Hello world', account: bob) + expect(FeedManager.instance.push_to_list(list, status)).to eq true + end + + it 'pushes statuses that are replies to list owner' do + status = Fabricate(:status, text: 'Hello world', account: owner) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq true + end + + it 'does not push replies to another member of the list' do + status = Fabricate(:status, text: 'Hello world', account: alice) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq false + end + end + + context 'when replies policy is set to list-only replies' do + before do + list.replies_policy = :list_replies + end + + it 'pushes statuses that are not replies' do + status = Fabricate(:status, text: 'Hello world', account: bob) + expect(FeedManager.instance.push_to_list(list, status)).to eq true + end + + it 'pushes statuses that are replies to list owner' do + status = Fabricate(:status, text: 'Hello world', account: owner) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq true + end + + it 'pushes replies to another member of the list' do + status = Fabricate(:status, text: 'Hello world', account: alice) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq true + end + + it 'does not push replies to someone not a member of the list' do + status = Fabricate(:status, text: 'Hello world', account: eve) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq false + end + end + + context 'when replies policy is set to any reply' do + before do + list.replies_policy = :all_replies + end + + it 'pushes statuses that are not replies' do + status = Fabricate(:status, text: 'Hello world', account: bob) + expect(FeedManager.instance.push_to_list(list, status)).to eq true + end + + it 'pushes statuses that are replies to list owner' do + status = Fabricate(:status, text: 'Hello world', account: owner) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq true + end + + it 'pushes replies to another member of the list' do + status = Fabricate(:status, text: 'Hello world', account: alice) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq true + end + + it 'pushes replies to someone not a member of the list' do + status = Fabricate(:status, text: 'Hello world', account: eve) + reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) + expect(FeedManager.instance.push_to_list(list, reply)).to eq true + end + end end describe '#merge_into_timeline' do diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 46886b91f..379872316 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -601,8 +601,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 @@ -647,8 +647,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 e8ef61f66..36e346f14 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -12,12 +12,19 @@ describe AccountInteractions do subject { Account.following_map(target_account_ids, account_id) } context 'account with Follow' do - it 'returns { target_account_id => true }' do + it 'returns { target_account_id => { reblogs: true } }' do Fabricate(:follow, account: account, target_account: target_account) is_expected.to eq(target_account_id => { reblogs: true }) end end + context 'account with Follow but with reblogs disabled' do + it 'returns { target_account_id => { reblogs: false } }' do + Fabricate(:follow, account: account, target_account: target_account, show_reblogs: false) + is_expected.to eq(target_account_id => { reblogs: false }) + end + end + context 'account without Follow' do it 'returns {}' do is_expected.to eq({}) @@ -569,7 +576,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 2cf28b263..4b824c0db 100644 --- a/spec/models/follow_request_spec.rb +++ b/spec/models/follow_request_spec.rb @@ -13,6 +13,13 @@ RSpec.describe FollowRequest, type: :model do 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! diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index e8cf18af9..8e90b92d0 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -232,6 +232,43 @@ RSpec.describe Status, type: :model do end end + describe 'on create' do + let(:local_account) { Fabricate(:account, username: 'local', domain: nil) } + let(:remote_account) { Fabricate(:account, username: 'remote', domain: 'example.com') } + + subject { Status.new } + + describe 'on a status that ends with the local-only emoji' do + before do + subject.text = 'A toot ' + subject.local_only_emoji + end + + context 'if the status originates from this instance' do + before do + subject.account = local_account + end + + it 'is marked local-only' do + subject.save! + + expect(subject).to be_local_only + end + end + + context 'if the status is remote' do + before do + subject.account = remote_account + end + + it 'is not marked local-only' do + subject.save! + + expect(subject).to_not be_local_only + end + end + end + end + describe '.mutes_map' do let(:status) { Fabricate(:status) } let(:account) { Fabricate(:account) } @@ -574,6 +611,32 @@ RSpec.describe Status, type: :model do end end end + + context 'with local-only statuses' do + let(:status) { Fabricate(:status, local_only: true) } + + subject { Status.as_public_timeline(viewer) } + + context 'without a viewer' do + let(:viewer) { nil } + + it 'excludes local-only statuses' do + expect(subject).to_not include(status) + end + end + + context 'with a viewer' do + let(:viewer) { Fabricate(:account, username: 'viewer') } + + it 'includes local-only statuses' do + expect(subject).to include(status) + end + end + + # TODO: What happens if the viewer is remote? + # Can the viewer be remote? + # What prevents the viewer from being remote? + end end describe '.as_tag_timeline' do @@ -595,6 +658,27 @@ RSpec.describe Status, type: :model do results = Status.as_tag_timeline(tag) expect(results).to include(status) end + + context 'on a local-only status' do + let(:tag) { Fabricate(:tag) } + let(:status) { Fabricate(:status, local_only: true, tags: [tag]) } + + context 'without a viewer' do + let(:viewer) { nil } + + it 'filters the local-only status out of the result set' do + expect(Status.as_tag_timeline(tag, viewer)).not_to include(status) + end + end + + context 'with a viewer' do + let(:viewer) { Fabricate(:account, username: 'viewer', domain: nil) } + + it 'keeps the local-only status in the result set' do + expect(Status.as_tag_timeline(tag, viewer)).to include(status) + end + end + end end describe '.permitted_for' do diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 1cddf4abd..8bce29cad 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -73,6 +73,18 @@ RSpec.describe StatusPolicy, type: :model do expect(subject).to_not permit(viewer, status) end + + it 'denies access when local-only and the viewer is not logged in' do + allow(status).to receive(:local_only?) { true } + + expect(subject).to_not permit(nil, status) + end + + it 'denies access when local-only and the viewer is from another domain' do + viewer = Fabricate(:account, domain: 'remote-domain') + allow(status).to receive(:local_only?) { true } + expect(subject).to_not permit(viewer, status) + end end permissions :reblog? do diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb index 93a4e88e4..81d8d0e98 100644 --- a/spec/presenters/instance_presenter_spec.rb +++ b/spec/presenters/instance_presenter_spec.rb @@ -91,8 +91,8 @@ describe InstancePresenter do end describe '#source_url' do - it 'returns "https://github.com/tootsuite/mastodon"' do - expect(instance_presenter.source_url).to eq('https://github.com/tootsuite/mastodon') + it 'returns "https://github.com/glitch-soc/mastodon"' do + expect(instance_presenter.source_url).to eq('https://github.com/glitch-soc/mastodon') end end diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 39a681abb..7bfca7fb5 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -47,7 +47,7 @@ RSpec.describe NotifyService, type: :service do recipient.update(suspended: true) is_expected.to_not change(Notification, :count) 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 11e55f933..62791cd2f 100644 --- a/spec/validators/status_length_validator_spec.rb +++ b/spec/validators/status_length_validator_spec.rb @@ -16,26 +16,31 @@ describe StatusLengthValidator do expect(status).not_to receive(:errors) end - 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) @@ -43,7 +48,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 c75c28759..26b131977 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') @@ -25,6 +27,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do user_count: 420, status_count: 69, active_user_count: 420, + commit_hash: commit_hash, contact_account: nil, sample_accounts: [] ) diff --git a/streaming/index.js b/streaming/index.js index 2a51a1a0d..d4fb8cad3 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -339,6 +339,12 @@ const startWorker = (workerId) => { return; } + // Only send local-only statuses to logged-in users + if (payload.local_only && !req.accountId) { + log.silly(req.requestId, `Message ${payload.id} filtered because it was local-only`); + return; + } + // Only messages that may require filtering are statuses, since notifications // are already personalized and deletes do not matter if (!needsFiltering || event !== 'update') { diff --git a/yarn.lock b/yarn.lock index 329216cdb..377a3523d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1526,6 +1526,10 @@ atob@^2.1.1: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atrament@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.3.tgz#6ccbc0daa6d3f25e5aeaeb31befeb78e86980348" + autoprefixer@^9.4.10: version "9.4.10" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.4.10.tgz#e1be61fc728bacac8f4252ed242711ec0dcc6a7b" @@ -3725,6 +3729,10 @@ fast-levenshtein@~2.0.4: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +favico.js@^0.3.10: + version "0.3.10" + resolved "https://registry.yarnpkg.com/favico.js/-/favico.js-0.3.10.tgz#80586e27a117f24a8d51c18a99bdc714d4339301" + faye-websocket@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" |