diff options
804 files changed, 62457 insertions, 704 deletions
diff --git a/.env.production.sample b/.env.production.sample index 6f14c5804..65f3f9d1f 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -10,11 +10,37 @@ # ---------- LOCAL_DOMAIN=example.com +# Use this only if you need to run mastodon on a different domain than the one used for federation. +# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md +# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING. +# WEB_DOMAIN=mastodon.example.com + +# Use this if you want to have several aliases handler@example1.com +# handler@example2.com etc. for the same user. LOCAL_DOMAIN should not +# be added. Comma separated values +# ALTERNATE_DOMAINS=example1.com,example2.com + +# Use HTTP proxy for outgoing request (optional) +# http_proxy=http://gateway.local:8118 +# Access control for hidden service. +# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true + +# Authorized fetch mode (optional) +# Require remote servers to authentify when fetching toots, see +# https://docs.joinmastodon.org/admin/config/#authorized_fetch +# AUTHORIZED_FETCH=true + +# Limited federation mode (optional) +# Only allow federation with specific domains, see +# https://docs.joinmastodon.org/admin/config/#whitelist_mode +# LIMITED_FEDERATION_MODE=true + # Redis # ----- REDIS_HOST=localhost REDIS_PORT=6379 + # PostgreSQL # ---------- DB_HOST=/var/run/postgresql @@ -23,26 +49,49 @@ DB_NAME=mastodon_production DB_PASS= DB_PORT=5432 + # ElasticSearch (optional) # ------------------------ -ES_ENABLED=true -ES_HOST=localhost -ES_PORT=9200 +#ES_ENABLED=true +#ES_HOST=localhost +#ES_PORT=9200 + # Secrets # ------- -# Make sure to use `rake secret` to generate secrets +# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose) # ------- SECRET_KEY_BASE= OTP_SECRET= + # Web Push # -------- -# Generate with `rake mastodon:webpush:generate_vapid_key` +# Generate with `rake mastodon:webpush:generate_vapid_key` (first is the private key, second is the public one) +# You should only generate this once per instance. If you later decide to change it, all push subscription will +# be invalidated, requiring the users to access the website again to resubscribe. # -------- VAPID_PRIVATE_KEY= VAPID_PUBLIC_KEY= + +# Registrations +# ------------- + +# Single user mode will disable registrations and redirect frontpage to the first profile +# SINGLE_USER_MODE=true + +# Prevent registrations with following e-mail domains +# EMAIL_DOMAIN_DENYLIST=example1.com|example2.de|etc + +# Only allow registrations with the following e-mail domains +# EMAIL_DOMAIN_ALLOWLIST=example1.com|example2.de|etc + +#TODO move this +# Optionally change default language +# DEFAULT_LOCALE=de + + # Sending mail # ------------ SMTP_SERVER=smtp.mailgun.org @@ -51,10 +100,179 @@ SMTP_LOGIN= SMTP_PASSWORD= SMTP_FROM_ADDRESS=notificatons@example.com + # File storage (optional) # ----------------------- -S3_ENABLED=true -S3_BUCKET=files.example.com -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -S3_ALIAS_HOST=files.example.com +# The attachment host must allow cross origin request from WEB_DOMAIN or +# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the +# following header field: +# Access-Control-Allow-Origin: https://192.168.1.123:9000/ +# ----------------------- +#S3_ENABLED=true +#S3_BUCKET=files.example.com +#AWS_ACCESS_KEY_ID= +#AWS_SECRET_ACCESS_KEY= +#S3_ALIAS_HOST=files.example.com + +# Swift (optional) +# The attachment host must allow cross origin request - see the description +# above. +# SWIFT_ENABLED=true +# SWIFT_USERNAME= +# For Keystone V3, the value for SWIFT_TENANT should be the project name +# SWIFT_TENANT= +# SWIFT_PASSWORD= +# Some OpenStack V3 providers require PROJECT_ID (optional) +# SWIFT_PROJECT_ID= +# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid +# issues with token rate-limiting during high load. +# SWIFT_AUTH_URL= +# SWIFT_CONTAINER= +# SWIFT_OBJECT_URL= +# SWIFT_REGION= +# Defaults to 'default' +# SWIFT_DOMAIN_NAME= +# Defaults to 60 seconds. Set to 0 to disable +# SWIFT_CACHE_TTL= + +# Optional asset host for multi-server setups +# The asset host must allow cross origin request from WEB_DOMAIN or LOCAL_DOMAIN +# if WEB_DOMAIN is not set. For example, the server may have the +# following header field: +# Access-Control-Allow-Origin: https://example.com/ +# CDN_HOST=https://assets.example.com + +# Optional list of hosts that are allowed to serve media for your instance +# This is useful if you include external media in your custom CSS or about page, +# or if your data storage provider makes use of redirects to other domains. +# EXTRA_DATA_HOSTS=https://data.example1.com|https://data.example2.com + +# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare) +# S3_ALIAS_HOST= + +# Streaming API integration +# STREAMING_API_BASE_URL= + + +# External authentication (optional) +# ---------------------------------- +# LDAP authentication (optional) +# LDAP_ENABLED=true +# LDAP_HOST=localhost +# LDAP_PORT=389 +# LDAP_METHOD=simple_tls +# LDAP_BASE= +# LDAP_BIND_DN= +# LDAP_PASSWORD= +# LDAP_UID=cn +# LDAP_MAIL=mail +# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email})) +# LDAP_UID_CONVERSION_ENABLED=true +# LDAP_UID_CONVERSION_SEARCH=., - +# LDAP_UID_CONVERSION_REPLACE=_ + +# PAM authentication (optional) +# PAM authentication uses for the email generation the "email" pam variable +# and optional as fallback PAM_DEFAULT_SUFFIX +# The pam environment variable "email" is provided by: +# https://github.com/devkral/pam_email_extractor +# PAM_ENABLED=true +# Fallback email domain for email address generation (LOCAL_DOMAIN by default) +# PAM_EMAIL_DOMAIN=example.com +# Name of the pam service (pam "auth" section is evaluated) +# PAM_DEFAULT_SERVICE=rpam +# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) +# PAM_CONTROLLED_SERVICE=rpam + +# Global OAuth settings (optional) : +# If you have only one strategy, you may want to enable this +# OAUTH_REDIRECT_AT_SIGN_IN=true + +# Optional CAS authentication (cf. omniauth-cas) : +# CAS_ENABLED=true +# CAS_URL=https://sso.myserver.com/ +# CAS_HOST=sso.myserver.com/ +# CAS_PORT=443 +# CAS_SSL=true +# CAS_VALIDATE_URL= +# CAS_CALLBACK_URL= +# CAS_LOGOUT_URL= +# CAS_LOGIN_URL= +# CAS_UID_FIELD='user' +# CAS_CA_PATH= +# CAS_DISABLE_SSL_VERIFICATION=false +# CAS_UID_KEY='user' +# CAS_NAME_KEY='name' +# CAS_EMAIL_KEY='email' +# CAS_NICKNAME_KEY='nickname' +# CAS_FIRST_NAME_KEY='firstname' +# CAS_LAST_NAME_KEY='lastname' +# CAS_LOCATION_KEY='location' +# CAS_IMAGE_KEY='image' +# CAS_PHONE_KEY='phone' + +# Optional SAML authentication (cf. omniauth-saml) +# SAML_ENABLED=true +# SAML_ACS_URL=http://localhost:3000/auth/auth/saml/callback +# SAML_ISSUER=https://example.com +# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO +# SAML_IDP_CERT= +# SAML_IDP_CERT_FINGERPRINT= +# SAML_NAME_IDENTIFIER_FORMAT= +# SAML_CERT= +# SAML_PRIVATE_KEY= +# SAML_SECURITY_WANT_ASSERTION_SIGNED=true +# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true +# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true +# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" +# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241" +# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42" +# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4" +# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_VERIFIED= +# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL= + + +# Custom settings +# --------------- +# Various ways to customize Mastodon's behavior +# --------------- + +# Maximum allowed character count +MAX_TOOT_CHARS=500 + +# Maximum number of pinned posts +MAX_PINNED_TOOTS=5 + +# Maximum allowed bio characters +MAX_BIO_CHARS=500 + +# Maximim number of profile fields allowed +MAX_PROFILE_FIELDS=4 + +# Maximum allowed display name characters +MAX_DISPLAY_NAME_CHARS=30 + +# Maximum allowed poll options +MAX_POLL_OPTIONS=5 + +# Maximum allowed poll option characters +MAX_POLL_OPTION_CHARS=100 + +# Maximum image and video/audio upload sizes +# Units are in bytes +# 1048576 bytes equals 1 megabyte +# MAX_IMAGE_SIZE=8388608 +# MAX_VIDEO_SIZE=41943040 + +# Maximum search results to display +# Only relevant when elasticsearch is installed +# MAX_SEARCH_RESULTS=20 + +# Maximum custom emoji file sizes +# If undefined or smaller than MAX_EMOJI_SIZE, the value +# of MAX_EMOJI_SIZE will be used for MAX_REMOTE_EMOJI_SIZE +# Units are in bytes +MAX_EMOJI_SIZE=51200 +MAX_REMOTE_EMOJI_SIZE=204800 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8ae4bb882..870394a91 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,24 +4,9 @@ about: If something isn't working as expected labels: bug --- -<!-- 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/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c4cd48878..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 99 - allow: - - dependency-type: direct - - - package-ecosystem: bundler - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 99 - allow: - - dependency-type: direct diff --git a/.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..cc0a7f51b 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 glitch-abuse@sitedethib.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 0d563559b..972fe2a10 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 🐘 @@ -45,3 +79,5 @@ It is not always possible to phrase every change in such a manner, but it is des ## Documentation The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to tootsuite/documentation](https://github.com/tootsuite/documentation). + +</blockquote> diff --git a/Gemfile b/Gemfile index a7187d691..b6412eeb6 100644 --- a/Gemfile +++ b/Gemfile @@ -99,6 +99,8 @@ gem 'json-ld' gem 'json-ld-preloaded', '~> 3.1' gem 'rdf-normalize', '~> 0.4' +gem 'redcarpet', '~> 3.5' + group :development, :test do gem 'fabrication', '~> 2.22' gem 'fuubar', '~> 2.5' diff --git a/Gemfile.lock b/Gemfile.lock index 201893d47..1fc65d31c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -490,6 +490,7 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.4.0) rdf (~> 3.1) + redcarpet (3.5.1) redis (4.2.5) redis-namespace (1.8.1) redis (>= 3.0.4) @@ -768,6 +769,7 @@ DEPENDENCIES rails-i18n (~> 6.0) rails-settings-cached (~> 0.6) rdf-normalize (~> 0.4) + redcarpet (~> 3.5) redis (~> 4.2) redis-namespace (~> 1.8) resolv (~> 0.1.0) diff --git a/README.md b/README.md index 9fa4ec007..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] -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] -[![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 -[crowdin]: https://crowdin.com/project/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 where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that 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 choices! - -## 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** 4+ -- **Ruby** 2.5+ -- **Node.js** 12+ - -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/admin/install/) 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 Crowdin. 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.libera.chat - -## License - -Copyright (C) 2016-2021 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 4d0cc0f76..2afed9279 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -97,7 +97,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 d7e78d6b9..620c0ff78 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -3,6 +3,8 @@ class AboutController < ApplicationController include RegistrationSpamConcern + before_action :set_pack + layout 'public' before_action :require_open_federation!, only: [:show, :more] @@ -54,6 +56,10 @@ class AboutController < ApplicationController end end + def set_pack + use_pack 'public' + 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 8210918d8..f9bd616e4 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -17,6 +17,7 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do + use_pack 'public' expires_in 0, public: true unless user_signed_in? @pinned_statuses = [] @@ -28,7 +29,7 @@ class AccountsController < ApplicationController return end - @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? + @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses? @statuses = cached_filtered_status_page @rss_url = rss_url @@ -73,7 +74,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/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c8b6dcc88..00f3d3cba 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -20,7 +20,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_items case params[:id] when 'featured' - @items = for_signed_account { cache_collection(@account.pinned_statuses, Status) } + @items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) } when 'tags' @items = for_signed_account { @account.featured_tags } when 'devices' 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 c829ed98f..a00d7ed96 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -35,6 +35,7 @@ module Admin @whitelist_enabled = whitelist_mode? @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview + @keybase_integration = Setting.enable_keybase @trends_enabled = Setting.trends end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index 503f85c97..1d3992a28 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController before_action :require_user! def index - accounts = Account.without_suspended.where(id: account_ids).select('id') + accounts = Account.where(id: account_ids).select('id') # .where doesn't guarantee that our results are in the same order # we requested them, so return the "right" order to the requestor. @accounts = accounts.index_by(&:id).values_at(*account_ids).compact diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index f9d780839..eefd28d45 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -23,11 +23,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_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224..c8529318f 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -45,6 +45,7 @@ class Api::V1::StatusesController < Api::BaseController scheduled_at: status_params[:scheduled_at], application: doorkeeper_token.application, poll: status_params[:poll], + content_type: status_params[:content_type], idempotency: request.headers['Idempotency-Key'], with_rate_limit: true) @@ -85,6 +86,7 @@ class Api::V1::StatusesController < Api::BaseController :spoiler_text, :visibility, :scheduled_at, + :content_type, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb new file mode 100644 index 000000000..6e98e9cac --- /dev/null +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::DirectController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show] + before_action :require_user!, only: [:show] + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + respond_to :json + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_direct_statuses + end + + def cached_direct_statuses + cache_collection direct_statuses, Status + end + + def direct_statuses + direct_timeline_statuses + end + + def direct_timeline_statuses + account_direct_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def account_direct_feed + DirectFeed.new(current_account) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index d253b744f..493fe4776 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -37,7 +37,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController current_account, local: truthy_param?(:local), remote: truthy_param?(:remote), - only_media: truthy_param?(:only_media) + only_media: truthy_param?(:only_media), + allow_local_only: truthy_param?(:allow_local_only), + with_replies: Setting.show_replies_in_public_timelines, + with_reblogs: Setting.show_reblogs_in_public_timelines, ) end @@ -46,7 +49,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def pagination_params(core_params) - params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params) + params.slice(:local, :remote, :limit, :only_media, :allow_local_only).permit(:local, :remote, :limit, :only_media, :allow_local_only).merge(core_params) end def next_path diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index f17431dd1..ddcf92200 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -3,7 +3,7 @@ class Api::V2::SearchController < Api::BaseController include Authorization - RESULTS_LIMIT = 20 + RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i before_action -> { doorkeeper_authorize! :read, :'read:search' } before_action :require_user! diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b4fb83661..9eb73d576 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? helper_method :whitelist_mode? @@ -67,6 +68,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) @@ -129,14 +199,22 @@ class ApplicationController < ActionController::Base @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? 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 respond_with_error(code) respond_to do |format| - format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } + format.any do + use_pack 'error' + render "errors/#{code}", layout: 'error', status: code, formats: [:html] + end format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } end end diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb index 060944240..41827b21c 100644 --- a/app/controllers/auth/challenges_controller.rb +++ b/app/controllers/auth/challenges_controller.rb @@ -5,6 +5,7 @@ class Auth::ChallengesController < ApplicationController layout 'auth' + before_action :set_pack before_action :authenticate_user! skip_before_action :require_functional! @@ -19,4 +20,10 @@ class Auth::ChallengesController < ApplicationController render_challenge end end + + private + + def set_pack + use_pack 'auth' + end end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 1475bbcef..0b5a2f3c9 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -4,6 +4,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' before_action :set_body_classes + before_action :set_pack before_action :require_unconfirmed! skip_before_action :require_functional! @@ -16,6 +17,10 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController private + def set_pack + use_pack 'auth' + end + def require_unconfirmed! if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? redirect_to(current_user.approved? ? root_path : edit_user_registration_path) diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 5db2668f7..42534f8ce 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' @@ -31,4 +32,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 a3114ab25..6429bd969 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -9,6 +9,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] @@ -101,6 +102,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 13d158c67..548832b21 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -9,6 +9,8 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_functional! skip_before_action :update_user_sign_in + prepend_before_action :set_pack + include TwoFactorAuthenticationConcern include SignInTokenAuthenticationConcern @@ -100,6 +102,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/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index 46c5f2958..db5a866f2 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -3,6 +3,7 @@ class Auth::SetupController < ApplicationController layout 'auth' + before_action :set_pack before_action :authenticate_user! before_action :require_unconfirmed_or_pending! before_action :set_body_classes @@ -55,4 +56,8 @@ class Auth::SetupController < ApplicationController def missing_email? truthy_param?(:missing_email) end + + def set_pack + use_pack 'auth' + end end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index 29c0288d0..f0bcac75b 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/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb index 3c95a4afd..51ebcb115 100644 --- a/app/controllers/concerns/sign_in_token_authentication_concern.rb +++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb @@ -45,6 +45,7 @@ module SignInTokenAuthenticationConcern end set_attempt_session(user) + use_pack 'auth' @body_classes = 'lighter' diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb index 4d4ccf49c..4800db348 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -75,6 +75,8 @@ module TwoFactorAuthenticationConcern def prompt_for_two_factor(user) set_attempt_session(user) + use_pack 'auth' + @body_classes = 'lighter' @webauthn_enabled = user.webauthn_enabled? @scheme_type = begin diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index f28c5b2af..2263f286b 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -7,6 +7,7 @@ class DirectoriesController < ApplicationController before_action :require_enabled! before_action :set_instance_presenter before_action :set_accounts + before_action :set_pack skip_before_action :require_functional!, unless: :whitelist_mode? @@ -16,6 +17,10 @@ class DirectoriesController < ApplicationController private + def set_pack + use_pack 'share' + end + def require_enabled! return not_found unless Setting.profile_directory end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 79a1ab02b..0d4c1b97c 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -6,6 +6,7 @@ class FiltersController < ApplicationController before_action :authenticate_user! 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 @@ -43,6 +44,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 ff4df2adf..18b281325 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -13,6 +13,7 @@ class FollowerAccountsController < ApplicationController def index respond_to do |format| format.html do + use_pack 'public' expires_in 0, public: true unless user_signed_in? next if @account.user_hides_network? @@ -61,22 +62,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 page_requested? 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: next_page_url, - prev: prev_page_url + prev: prev_page_url, + **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 6bb95c454..eba44d34b 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -13,6 +13,7 @@ class FollowingAccountsController < ApplicationController def index respond_to do |format| format.html do + use_pack 'public' expires_in 0, public: true 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 702889cd0..c9b840881 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -3,6 +3,8 @@ class HomeController < ApplicationController before_action :redirect_unauthenticated_to_permalinks! before_action :authenticate_user! + + before_action :set_pack before_action :set_referrer_policy_header def index @@ -40,6 +42,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 default_redirect_path if request.path.start_with?('/web') || whitelist_mode? new_user_session_path diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 8d92147e2..0b3c082dc 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 current_user.invites.order(id: :desc) end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index ce015dd1b..772fc42cb 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -11,6 +11,7 @@ class MediaController < ApplicationController before_action :verify_permitted_status! before_action :check_playable, only: :player before_action :allow_iframing, only: :player + before_action :set_pack, only: :player content_security_policy only: :player do |p| p.frame_ancestors(false) @@ -43,4 +44,8 @@ class MediaController < ApplicationController def allow_iframing response.headers['X-Frame-Options'] = 'ALLOWALL' end + + def set_pack + use_pack 'public' + end end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bb5d639ce..137346ed0 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 before_action :set_cache_headers include Localized @@ -15,6 +16,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 45151cdd7..b2564a791 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 :require_not_suspended!, only: :destroy before_action :set_body_classes @@ -27,6 +28,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio store_location_for(:user, request.url) end + def set_pack + use_pack 'settings' + end + def require_not_suspended! forbidden if current_account.suspended? end diff --git a/app/controllers/public_timelines_controller.rb b/app/controllers/public_timelines_controller.rb index 1332ba16c..eb5bb191b 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 :authenticate_user!, if: :whitelist_mode? @@ -23,4 +24,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 96cce55e9..d40770726 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_relationships, only: :show before_action :set_body_classes @@ -68,4 +69,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 db1604644..93a0a7476 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -5,6 +5,7 @@ class RemoteFollowController < ApplicationController layout 'modal' + before_action :set_pack before_action :set_body_classes skip_before_action :require_functional! @@ -34,6 +35,10 @@ class RemoteFollowController < ApplicationController { acct: session[:remote_follow] || current_account&.username } end + def set_pack + use_pack 'modal' + end + def set_body_classes @body_classes = 'modal-layout' @hide_header = true diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index 6c29a2b9f..a277bfa10 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -9,6 +9,7 @@ class RemoteInteractionController < ApplicationController before_action :set_interaction_type before_action :set_status before_action :set_body_classes + before_action :set_pack skip_before_action :require_functional!, unless: :whitelist_mode? @@ -49,6 +50,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/base_controller.rb b/app/controllers/settings/base_controller.rb index 8311538a5..dee3922d8 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Settings::BaseController < ApplicationController + before_action :set_pack layout 'admin' before_action :authenticate_user! @@ -9,6 +10,10 @@ class Settings::BaseController < ApplicationController private + def set_pack + use_pack 'settings' + end + def set_body_classes @body_classes = 'admin' end diff --git a/app/controllers/settings/flavours_controller.rb b/app/controllers/settings/flavours_controller.rb new file mode 100644 index 000000000..62c52eee9 --- /dev/null +++ b/app/controllers/settings/flavours_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Settings::FlavoursController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + + skip_before_action :require_functional! + + 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 bf2899da6..4618c7883 100644 --- a/app/controllers/settings/identity_proofs_controller.rb +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -2,6 +2,7 @@ class Settings::IdentityProofsController < Settings::BaseController 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) @@ -42,6 +43,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/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 32b5d7948..d05ceb53f 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -38,6 +38,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,12 +46,14 @@ class Settings::PreferencesController < Settings::BaseController :setting_reduce_motion, :setting_disable_swiping, :setting_system_font_ui, + :setting_system_emoji_font, :setting_noindex, - :setting_theme, :setting_hide_network, + :setting_hide_followers_count, :setting_aggregate_reblogs, :setting_show_application, :setting_advanced_layout, + :setting_default_content_type, :setting_use_blurhash, :setting_use_pending_items, :setting_trends, diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index 1c557092b..bd6f83134 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -84,6 +84,10 @@ module Settings private + def set_pack + use_pack 'auth' + end + def require_otp_enabled unless current_user.otp_enabled? flash[:error] = t('webauthn_credentials.otp_required') diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 6546b8497..e13e7e8b6 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -4,12 +4,17 @@ class SharesController < ApplicationController layout 'modal' before_action :authenticate_user! + before_action :set_pack before_action :set_body_classes def show; end private + 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 c52170d08..3812f541e 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' + expires_in 10.seconds, public: true if current_account.nil? set_ancestors set_descendants @@ -45,6 +47,7 @@ class StatusesController < ApplicationController end def embed + use_pack 'embed' return not_found if @status.hidden? || @status.reblog? expires_in 180, public: true diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 6616ba107..64736e77f 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -21,6 +21,7 @@ class TagsController < ApplicationController def show respond_to do |format| format.html do + use_pack 'about' expires_in 0, public: true end diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb index e1d43ecbe..03232df2d 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, root: 'keybase_config' end + + private + + def check_enabled + head 404 unless Setting.enable_keybase + end end end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 134217734..e977db2c6 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -77,8 +77,12 @@ module AccountsHelper 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), @@ -88,14 +92,16 @@ module AccountsHelper 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 svg_logo diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bf5742d34..5a9496bd4 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -128,7 +128,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/javascript/packs/admin.js b/app/javascript/core/admin.js index 8fd1b8a8e..d2db89ca7 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/core/admin.js @@ -1,4 +1,6 @@ -import './public-path'; +// This file will be loaded on admin pages, regardless of theme. + +import 'packs/public-path'; import { delegate } from '@rails/ujs'; import ready from '../mastodon/ready'; diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js new file mode 100644 index 000000000..d1d14d99e --- /dev/null +++ b/app/javascript/core/auth.js @@ -0,0 +1,3 @@ +import 'packs/public-path'; +import './settings'; +import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js new file mode 100644 index 000000000..1cee2f603 --- /dev/null +++ b/app/javascript/core/common.js @@ -0,0 +1,6 @@ +// This file will be loaded on all pages, regardless of theme. + +import 'packs/public-path'; +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..9083eb7a3 --- /dev/null +++ b/app/javascript/core/embed.js @@ -0,0 +1,25 @@ +// This file will be loaded on embed pages, regardless of theme. + +import 'packs/public-path'; + +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..b67fb13e5 --- /dev/null +++ b/app/javascript/core/public.js @@ -0,0 +1,52 @@ +// This file will be loaded on public pages, regardless of theme. + +import 'packs/public-path'; +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, '.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') !== 'true' && 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..d5bb9532c --- /dev/null +++ b/app/javascript/core/settings.js @@ -0,0 +1,77 @@ +// This file will be loaded on settings pages, regardless of theme. + +import 'packs/public-path'; +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 = name.textContent = target.dataset.default; + } + } +}); + +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 (lock) { + if (target.checked) { + delete lock.dataset.hidden; + } else { + lock.dataset.hidden = 'true'; + } + } +}); + +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..b9144e43a --- /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: auth.js + 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/packs/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js index dde06be8c..f076cdf30 100644 --- a/app/javascript/packs/two_factor_authentication.js +++ b/app/javascript/core/two_factor_authentication.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import axios from 'axios'; import * as WebAuthnJSON from '@github/webauthn-json'; import ready from '../mastodon/ready'; diff --git a/app/javascript/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js new file mode 100644 index 000000000..c1cce3193 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/account_notes.js @@ -0,0 +1,69 @@ +import api from 'flavours/glitch/util/api'; + +export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; +export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; +export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; + +export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; +export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; + +export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; + +export function submitAccountNote() { + return (dispatch, getState) => { + dispatch(submitAccountNoteRequest()); + + const id = getState().getIn(['account_notes', 'edit', 'account_id']); + + api(getState).post(`/api/v1/accounts/${id}/note`, { + comment: getState().getIn(['account_notes', 'edit', 'comment']), + }).then(response => { + dispatch(submitAccountNoteSuccess(response.data)); + }).catch(error => dispatch(submitAccountNoteFail(error))); + }; +}; + +export function submitAccountNoteRequest() { + return { + type: ACCOUNT_NOTE_SUBMIT_REQUEST, + }; +}; + +export function submitAccountNoteSuccess(relationship) { + return { + type: ACCOUNT_NOTE_SUBMIT_SUCCESS, + relationship, + }; +}; + +export function submitAccountNoteFail(error) { + return { + type: ACCOUNT_NOTE_SUBMIT_FAIL, + error, + }; +}; + +export function initEditAccountNote(account) { + return (dispatch, getState) => { + const comment = getState().getIn(['relationships', account.get('id'), 'note']); + + dispatch({ + type: ACCOUNT_NOTE_INIT_EDIT, + account, + comment, + }); + }; +}; + +export function cancelAccountNote() { + return { + type: ACCOUNT_NOTE_CANCEL, + }; +}; + +export function changeAccountNoteComment(comment) { + return { + type: ACCOUNT_NOTE_CHANGE_COMMENT, + comment, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js new file mode 100644 index 000000000..912a3d179 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -0,0 +1,843 @@ +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, options = { reblogs: true }) { + return (dispatch, getState) => { + const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + const locked = getState().getIn(['accounts', id, 'locked'], false); + + dispatch(followAccountRequest(id, locked)); + + api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { + dispatch(followAccountSuccess(response.data, alreadyFollowing)); + }).catch(error => { + dispatch(followAccountFail(error, locked)); + }); + }; +}; + +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, locked) { + return { + type: ACCOUNT_FOLLOW_REQUEST, + id, + locked, + skipLoading: true, + }; +}; + +export function followAccountSuccess(relationship, alreadyFollowing) { + return { + type: ACCOUNT_FOLLOW_SUCCESS, + relationship, + alreadyFollowing, + skipLoading: true, + }; +}; + +export function followAccountFail(error, locked) { + return { + type: ACCOUNT_FOLLOW_FAIL, + error, + locked, + skipLoading: true, + }; +}; + +export function unfollowAccountRequest(id) { + return { + type: ACCOUNT_UNFOLLOW_REQUEST, + id, + skipLoading: true, + }; +}; + +export function unfollowAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_UNFOLLOW_SUCCESS, + relationship, + statuses, + skipLoading: true, + }; +}; + +export function unfollowAccountFail(error) { + return { + type: ACCOUNT_UNFOLLOW_FAIL, + error, + skipLoading: true, + }; +}; + +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, duration=0) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).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, + skipNotFound: true, + }; +}; + +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, + skipNotFound: true, + }; +}; + +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, + skipNotFound: 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..1670f9c10 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -0,0 +1,63 @@ +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.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, +}); + +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; +export const ALERT_NOOP = 'ALERT_NOOP'; + +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, message_values = undefined) { + return { + type: ALERT_SHOW, + title, + message, + message_values, + }; +}; + +export function showAlertForError(error, skipNotFound = false) { + if (error.response) { + const { data, status, statusText, headers } = error.response; + + if (skipNotFound && (status === 404 || status === 410)) { + // Skip these errors as they are reflected in the UI + return { type: ALERT_NOOP }; + } + + if (status === 429 && headers['x-ratelimit-reset']) { + const reset_date = new Date(headers['x-ratelimit-reset']); + return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + } + + 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/announcements.js b/app/javascript/flavours/glitch/actions/announcements.js new file mode 100644 index 000000000..871409d43 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/announcements.js @@ -0,0 +1,180 @@ +import api from 'flavours/glitch/util/api'; +import { normalizeAnnouncement } from './importer/normalizer'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => { + dispatch(fetchAnnouncementsRequest()); + + api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x)))); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); +}; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = announcements => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail= error => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = announcement => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: normalizeAnnouncement(announcement), +}); + +export const dismissAnnouncement = announcementId => (dispatch, getState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); +}; + +export const dismissAnnouncementRequest = announcementId => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = announcementId => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId, error) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId, name) => (dispatch, getState) => { + const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); +}; + +export const addReactionRequest = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId, name, error) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(announcementId, name)); + + api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); +}; + +export const removeReactionRequest = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId, name, error) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = reaction => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = id => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js new file mode 100644 index 000000000..adae9d83c --- /dev/null +++ b/app/javascript/flavours/glitch/actions/blocks.js @@ -0,0 +1,99 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; +import { openModal } from './modal'; + +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 const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; + +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, + }; +}; + +export function initBlockModal(account) { + return dispatch => { + dispatch({ + type: BLOCKS_INIT_MODAL, + account, + }); + + dispatch(openModal('BLOCK')); + }; +} 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/boosts.js b/app/javascript/flavours/glitch/actions/boosts.js new file mode 100644 index 000000000..6e14065d6 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/boosts.js @@ -0,0 +1,29 @@ +import { openModal } from './modal'; + +export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; +export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; + +export function initBoostModal(props) { + return (dispatch, getState) => { + const default_privacy = getState().getIn(['compose', 'default_privacy']); + + const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy; + + dispatch({ + type: BOOSTS_INIT_MODAL, + privacy + }); + + dispatch(openModal('BOOST', props)); + }; +} + + +export function changeBoostPrivacy(privacy) { + return dispatch => { + dispatch({ + type: BOOSTS_CHANGE_PRIVACY, + privacy, + }); + }; +} 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..f83738093 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -0,0 +1,685 @@ +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, cancelFetchComposeSuggestionsTags; + +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 THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST'; +export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS'; +export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL'; +export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS'; + +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_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_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.' }, +}); + +const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); + +export const ensureComposeIsVisible = (getState, routerHistory) => { + if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { + routerHistory.push('/statuses/new'); + } +}; + +export function changeCompose(text) { + return { + type: COMPOSE_CHANGE, + text: text, + }; +}; + +export function cycleElefriendCompose() { + return { + type: COMPOSE_CYCLE_ELEFRIEND, + }; +}; + +export function replyCompose(status, routerHistory) { + return (dispatch, getState) => { + const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); + dispatch({ + type: COMPOSE_REPLY, + status: status, + prependCWRe: prependCWRe, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function cancelReplyCompose() { + return { + type: COMPOSE_REPLY_CANCEL, + }; +}; + +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + +export function mentionCompose(account, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_MENTION, + account: account, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function directCompose(account, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function submitCompose(routerHistory) { + return function (dispatch, getState) { + let status = getState().getIn(['compose', 'text'], ''); + let media = getState().getIn(['compose', 'media_attachments']); + const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); + let spoilerText = spoilers ? 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, + content_type: getState().getIn(['compose', 'content_type']), + 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'); + if (!response.data.local_only) { + 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 pending = getState().getIn(['compose', 'pending_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 + pending > 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/v2/media', data, { + onUploadProgress: function({ loaded }){ + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }, + }).then(({ status, data }) => { + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + + if (status === 200) { + dispatch(uploadComposeSuccess(data, f)); + } else if (status === 202) { + const poll = () => { + api(getState).get(`/api/v1/media/${data.id}`).then(response => { + if (response.status === 200) { + dispatch(uploadComposeSuccess(response.data, f)); + } else if (response.status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => dispatch(uploadComposeFail(error))); + }; + + poll(); + } + }); + }).catch(error => dispatch(uploadComposeFail(error))); + }; + }; +}; + +export const uploadThumbnail = (id, file) => (dispatch, getState) => { + dispatch(uploadThumbnailRequest()); + + const total = file.size; + const data = new FormData(); + + data.append('thumbnail', file); + + api(getState).put(`/api/v1/media/${id}`, data, { + onUploadProgress: ({ loaded }) => { + dispatch(uploadThumbnailProgress(loaded, total)); + }, + }).then(({ data }) => { + dispatch(uploadThumbnailSuccess(data)); + }).catch(error => { + dispatch(uploadThumbnailFail(id, error)); + }); +}; + +export const uploadThumbnailRequest = () => ({ + type: THUMBNAIL_UPLOAD_REQUEST, + skipLoading: true, +}); + +export const uploadThumbnailProgress = (loaded, total) => ({ + type: THUMBNAIL_UPLOAD_PROGRESS, + loaded, + total, + skipLoading: true, +}); + +export const uploadThumbnailSuccess = media => ({ + type: THUMBNAIL_UPLOAD_SUCCESS, + media, + skipLoading: true, +}); + +export const uploadThumbnailFail = error => ({ + type: THUMBNAIL_UPLOAD_FAIL, + error, + skipLoading: true, +}); + +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, file) { + return { + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + file: file, + 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 = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsTags) { + cancelFetchComposeSuggestionsTags(); + } + + dispatch(updateSuggestionTags(token)); + + api(getState).get('/api/v2/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsTags = cancel; + }), + + params: { + type: 'hashtags', + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(({ data }) => { + dispatch(readyComposeSuggestionsTags(token, data.hashtags)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } + }); +}, 200, { leading: true, trailing: true }); + +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 const readyComposeSuggestionsTags = (token, tags) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + tags, +}); + +export function selectComposeSuggestion(position, token, suggestion, path) { + return (dispatch, getState) => { + let completion; + if (suggestion.type === 'emoji') { + dispatch(useEmoji(suggestion)); + completion = suggestion.native || suggestion.colons; + } else if (suggestion.type === 'hashtag') { + completion = `#${suggestion.name}`; + } else if (suggestion.type === 'account') { + completion = '@' + getState().getIn(['accounts', suggestion.id, '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 changeComposeContentType(value) { + return { + type: COMPOSE_CONTENT_TYPE_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/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js new file mode 100644 index 000000000..e5c85c65d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/conversations.js @@ -0,0 +1,112 @@ +import api, { getLinks } from 'flavours/glitch/util/api'; +import { + importFetchedAccounts, + importFetchedStatuses, + importFetchedStatus, +} from './importer'; + +export const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; +export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; + +export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; +export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; +export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; +export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; + +export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; + +export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST'; +export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS'; +export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL'; + +export const mountConversations = () => ({ + type: CONVERSATIONS_MOUNT, +}); + +export const unmountConversations = () => ({ + type: CONVERSATIONS_UNMOUNT, +}); + +export const markConversationRead = conversationId => (dispatch, getState) => { + dispatch({ + type: CONVERSATIONS_READ, + id: conversationId, + }); + + api(getState).post(`/api/v1/conversations/${conversationId}/read`); +}; + +export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { + dispatch(expandConversationsRequest()); + + const params = { max_id: maxId }; + + if (!maxId) { + params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']); + } + + const isLoadingRecent = !!params.since_id; + + api(getState).get('/api/v1/conversations', { params }) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); + dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); + dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); + }) + .catch(err => dispatch(expandConversationsFail(err))); +}; + +export const expandConversationsRequest = () => ({ + type: CONVERSATIONS_FETCH_REQUEST, +}); + +export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({ + type: CONVERSATIONS_FETCH_SUCCESS, + conversations, + next, + isLoadingRecent, +}); + +export const expandConversationsFail = error => ({ + type: CONVERSATIONS_FETCH_FAIL, + error, +}); + +export const updateConversations = conversation => dispatch => { + dispatch(importFetchedAccounts(conversation.accounts)); + + if (conversation.last_status) { + dispatch(importFetchedStatus(conversation.last_status)); + } + + dispatch({ + type: CONVERSATIONS_UPDATE, + conversation, + }); +}; + +export const deleteConversation = conversationId => (dispatch, getState) => { + dispatch(deleteConversationRequest(conversationId)); + + api(getState).delete(`/api/v1/conversations/${conversationId}`) + .then(() => dispatch(deleteConversationSuccess(conversationId))) + .catch(error => dispatch(deleteConversationFail(conversationId, error))); +}; + +export const deleteConversationRequest = id => ({ + type: CONVERSATIONS_DELETE_REQUEST, + id, +}); + +export const deleteConversationSuccess = id => ({ + type: CONVERSATIONS_DELETE_SUCCESS, + id, +}); + +export const deleteConversationFail = (id, error) => ({ + type: CONVERSATIONS_DELETE_FAIL, + id, + error, +}); 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/directory.js b/app/javascript/flavours/glitch/actions/directory.js new file mode 100644 index 000000000..9fbfb7f5b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/directory.js @@ -0,0 +1,61 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); 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..6d3f471fa --- /dev/null +++ b/app/javascript/flavours/glitch/actions/domain_blocks.js @@ -0,0 +1,166 @@ +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..fb6e55612 --- /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, scroll_key) { + return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key }; +} + +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..18e679aec --- /dev/null +++ b/app/javascript/flavours/glitch/actions/identity_proofs.js @@ -0,0 +1,31 @@ +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, + skipNotFound: true, +}); 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..9b3bd0d56 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -0,0 +1,96 @@ +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 searchTextFromRawStatus (status) { + const spoilerText = status.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; +} + +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); + account.note_plain = unescapeHTML(account.note); + + if (account.fields) { + account.fields = account.fields.map(pair => ({ + ...pair, + name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap), + 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].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).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, index) => ({ + ...option, + voted: poll.own_votes && poll.own_votes.includes(index), + title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), + })); + + return normalPoll; +} + +export function normalizeAnnouncement(announcement) { + const normalAnnouncement = { ...announcement }; + const emojiMap = makeEmojiMap(normalAnnouncement); + + normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + + return normalAnnouncement; +} diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js new file mode 100644 index 000000000..336c8fa90 --- /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, visibility) { + return function (dispatch, getState) { + dispatch(reblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).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/markers.js b/app/javascript/flavours/glitch/actions/markers.js new file mode 100644 index 000000000..a086def97 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -0,0 +1,148 @@ +import api from 'flavours/glitch/util/api'; +import { debounce } from 'lodash'; +import compareId from 'flavours/glitch/util/compare_id'; + +export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; +export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; +export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; +export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; + +export const synchronouslySubmitMarkers = () => (dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = _buildParams(getState()); + + if (Object.keys(params).length === 0) { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if (window.fetch && 'keepalive' in new Request('')) { + fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + } else if (navigator && navigator.sendBeacon) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.SUBMIT(JSON.stringify(params)); + } catch (e) { + // Do not make the BeforeUnload handler error out + } +}; + +const _buildParams = (state) => { + const params = {}; + + const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null); + const lastNotificationId = state.getIn(['notifications', 'lastReadId']); + + if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +const debouncedSubmitMarkers = debounce((dispatch, getState) => { + const params = _buildParams(getState()); + + if (Object.keys(params).length === 0) { + return; + } + + api(getState).post('/api/v1/markers', params).then(() => { + dispatch(submitMarkersSuccess(params)); + }).catch(() => {}); +}, 300000, { leading: true, trailing: true }); + +export function submitMarkersSuccess({ home, notifications }) { + return { + type: MARKERS_SUBMIT_SUCCESS, + home: (home || {}).last_read_id, + notifications: (notifications || {}).last_read_id, + }; +}; + +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + + return result; +}; + +export const fetchMarkers = () => (dispatch, getState) => { + const params = { timeline: ['notifications'] }; + + dispatch(fetchMarkersRequest()); + + api(getState).get('/api/v1/markers', { params }).then(response => { + dispatch(fetchMarkersSuccess(response.data)); + }).catch(error => { + dispatch(fetchMarkersFail(error)); + }); +}; + +export function fetchMarkersRequest() { + return { + type: MARKERS_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchMarkersSuccess(markers) { + return { + type: MARKERS_FETCH_SUCCESS, + markers, + skipLoading: true, + }; +}; + +export function fetchMarkersFail(error) { + return { + type: MARKERS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js new file mode 100644 index 000000000..3d0299db5 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/modal.js @@ -0,0 +1,17 @@ +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(type) { + return { + type: MODAL_CLOSE, + modalType: type, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js new file mode 100644 index 000000000..2bacfadb7 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -0,0 +1,116 @@ +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 const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; + +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 }); + }; +} + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js new file mode 100644 index 000000000..bd3a34e5d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -0,0 +1,373 @@ +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 { submitMarkers } from './markers'; +import { saveSettings } from './settings'; +import { defineMessages } from 'react-intl'; +import { List as ImmutableList } from 'immutable'; +import { unescapeHTML } from 'flavours/glitch/util/html'; +import { getFiltersRegex } from 'flavours/glitch/selectors'; +import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; +import compareId from 'flavours/glitch/util/compare_id'; +import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; +import { requestNotificationPermission } from 'flavours/glitch/util/notifications'; + +export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; + +// 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_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; + +export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; +export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; + +export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; + + +export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; + +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + +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 const loadPending = () => ({ + type: NOTIFICATIONS_LOAD_PENDING, +}); + +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 = getFiltersRegex(getState(), { contextType: 'notifications' }); + + let filtered = false; + + if (['mention', 'status'].includes(notification.type)) { + const dropRegex = filters[0]; + const regex = filters[1]; + const searchIndex = searchTextFromRawStatus(notification.status); + + if (dropRegex && dropRegex.test(searchIndex)) { + return; + } + + filtered = regex && regex.test(searchIndex); + } + + dispatch(submitMarkers()); + + if (showInColumn) { + dispatch(importFetchedAccount(notification.account)); + + if (notification.status) { + dispatch(importFetchedStatus(notification.status)); + } + + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + usePendingItems: preferPendingItems, + 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', 'follow_request', '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 (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { + const a = notifications.getIn(['pendingItems', 0, 'id']); + const b = notifications.getIn(['items', 0, 'id']); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } + } + + const isLoadingRecent = !!params.since_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, isLoadingRecent, isLoadingRecent && preferPendingItems)); + fetchRelatedRelationships(dispatch, response.data); + dispatch(submitMarkers()); + }).catch(error => { + dispatch(expandNotificationsFail(error, isLoadingMore)); + }).finally(() => { + done(); + }); + }; +}; + +export function expandNotificationsRequest(isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_REQUEST, + skipLoading: !isLoadingMore, + }; +}; + +export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) { + return { + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + next, + isLoadingRecent: isLoadingRecent, + usePendingItems, + skipLoading: !isLoadingMore, + }; +}; + +export function expandNotificationsFail(error, isLoadingMore) { + return { + type: NOTIFICATIONS_EXPAND_FAIL, + error, + skipLoading: !isLoadingMore, + skipAlert: !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()); + }; +}; + +export function markNotificationsAsRead() { + return { + type: NOTIFICATIONS_MARK_AS_READ, + }; +}; + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }).catch(console.warn); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} 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/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js new file mode 100644 index 000000000..4085cb59e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js @@ -0,0 +1,38 @@ +// @ts-check + +export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; +export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; + +/** + * @typedef MediaProps + * @property {string} src + * @property {boolean} muted + * @property {number} volume + * @property {number} currentTime + * @property {string} poster + * @property {string} backgroundColor + * @property {string} foregroundColor + * @property {string} accentColor + */ + +/** + * @param {string} statusId + * @param {string} accountId + * @param {string} playerType + * @param {MediaProps} props + * @return {object} + */ +export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ + type: PICTURE_IN_PICTURE_DEPLOY, + statusId, + accountId, + playerType, + props, +}); + +/* + * @return {object} + */ +export const removePictureInPicture = () => ({ + type: PICTURE_IN_PICTURE_REMOVE, +}); 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..ca94a095f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/polls.js @@ -0,0 +1,60 @@ +import api from 'flavours/glitch/util/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..b4aee4525 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/search.js @@ -0,0 +1,131 @@ +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 const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_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) { + dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); + 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, value)); + 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, searchTerm) { + return { + type: SEARCH_FETCH_SUCCESS, + results, + searchTerm, + }; +}; + +export function fetchSearchFail(error) { + return { + type: SEARCH_FETCH_FAIL, + error, + }; +}; + +export const expandSearch = type => (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const offset = getState().getIn(['search', 'results', type]).size; + + dispatch(expandSearchRequest()); + + api(getState).get('/api/v2/search', { + params: { + q: value, + type, + offset, + }, + }).then(({ data }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type)); + dispatch(fetchRelationships(data.accounts.map(item => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); +}; + +export const expandSearchRequest = () => ({ + type: SEARCH_EXPAND_REQUEST, +}); + +export const expandSearchSuccess = (results, searchTerm, searchType) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, +}); + +export const expandSearchFail = error => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +export const showSearch = () => ({ + 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..4d2bda78b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -0,0 +1,241 @@ +import api from 'flavours/glitch/util/api'; + +import { deleteFromTimelines } from './timelines'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { ensureComposeIsVisible } from './compose'; + +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, raw_text, content_type) { + return { + type: REDRAFT, + status, + raw_text, + content_type, + }; +}; + +export function deleteStatus(id, routerHistory, 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(response => { + dispatch(deleteStatusSuccess(id)); + dispatch(deleteFromTimelines(id)); + + if (withRedraft) { + dispatch(redraft(status, response.data.text, response.data.content_type)); + + ensureComposeIsVisible(getState, routerHistory); + } + }).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..9dbc0b214 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/store.js @@ -0,0 +1,39 @@ +import { Iterable, fromJS } from 'immutable'; +import { hydrateCompose } from './compose'; +import { importFetchedAccounts } from './importer'; +import { saveSettings } from './settings'; + +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()); + +const applyMigrations = (state) => { + return state.withMutations(state => { + // Migrate glitch-soc local-only “Show unread marker” setting to Mastodon's setting + if (state.getIn(['local_settings', 'notifications', 'show_unread']) !== undefined) { + // Only change if the Mastodon setting does not deviate from default + if (state.getIn(['settings', 'notifications', 'showUnread']) !== false) { + state.setIn(['settings', 'notifications', 'showUnread'], state.getIn(['local_settings', 'notifications', 'show_unread'])); + } + state.removeIn(['local_settings', 'notifications', 'show_unread']) + } + }); +}; + +export function hydrateStore(rawState) { + return dispatch => { + const state = applyMigrations(convertState(rawState)); + + dispatch({ + type: STORE_HYDRATE, + state, + }); + + dispatch(hydrateCompose()); + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js new file mode 100644 index 000000000..35db5dcc9 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -0,0 +1,159 @@ +// @ts-check + +import { connectStream } from 'flavours/glitch/util/stream'; +import { + updateTimeline, + deleteFromTimelines, + expandHomeTimeline, + connectTimeline, + disconnectTimeline, +} from './timelines'; +import { updateNotifications, expandNotifications } from './notifications'; +import { updateConversations } from './conversations'; +import { + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, + deleteAnnouncement, +} from './announcements'; +import { fetchFilters } from './filters'; +import { getLocale } from 'mastodon/locales'; + +const { messages } = getLocale(); + +/** + * @param {number} max + * @return {number} + */ +const randomUpTo = max => + Math.floor(Math.random() * Math.floor(max)); + +/** + * @param {string} timelineId + * @param {string} channelName + * @param {Object.<string, string>} params + * @param {Object} options + * @param {function(Function, Function): void} [options.fallback] + * @param {function(object): boolean} [options.accept] + * @return {function(): void} + */ +export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => + connectStream(channelName, params, (dispatch, getState) => { + const locale = getState().getIn(['meta', 'locale']); + + let pollingId; + + /** + * @param {function(Function, Function): void} fallback + */ + const useFallback = fallback => { + fallback(dispatch, () => { + pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); + }); + }; + + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + + if (pollingId) { + clearTimeout(pollingId); + pollingId = null; + } + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + + if (options.fallback) { + pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000)); + } + }, + + onReceive (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; + case 'filters_changed': + dispatch(fetchFilters()); + break; + case 'announcement': + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; + } + }, + }; + }); + +/** + * @param {Function} dispatch + * @param {function(): void} done + */ +const refreshHomeTimelineAndNotification = (dispatch, done) => { + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); +}; + +/** + * @return {function(): void} + */ +export const connectUserStream = () => + connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification }); + +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @return {function(): void} + */ +export const connectCommunityStream = ({ onlyMedia } = {}) => + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); + +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @param {boolean} [options.onlyRemote] + * @param {boolean} [options.allowLocalOnly] + * @return {function(): void} + */ +export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => + connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`); + +/** + * @param {string} columnId + * @param {string} tagName + * @param {boolean} onlyLocal + * @param {function(object): boolean} accept + * @return {function(): void} + */ +export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) => + connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept }); + +/** + * @return {function(): void} + */ +export const connectDirectStream = () => + connectTimelineStream('direct', 'direct'); + +/** + * @param {string} listId + * @return {function(): void} + */ +export const connectListStream = listId => + connectTimelineStream(`list:${listId}`, 'list', { list: listId }); diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js new file mode 100644 index 000000000..7070250e3 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/suggestions.js @@ -0,0 +1,64 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +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(withRelationships = false) { + return (dispatch, getState) => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => { + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchSuggestionsSuccess(response.data)); + + if (withRelationships) { + dispatch(fetchRelationships(response.data.map(item => item.account.id))); + } + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }; +}; + +export function fetchSuggestionsRequest() { + return { + type: SUGGESTIONS_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchSuggestionsSuccess(suggestions) { + return { + type: SUGGESTIONS_FETCH_SUCCESS, + suggestions, + 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}`).then(() => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v2/suggestions').then(response => { + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchSuggestionsSuccess(response.data)); + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }).catch(() => {}); +}; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js new file mode 100644 index 000000000..24cc0d63f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -0,0 +1,214 @@ +import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { submitMarkers } from './markers'; +import api, { getLinks } from 'flavours/glitch/util/api'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; +import { me, usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; +import { getFiltersRegex } from 'flavours/glitch/selectors'; +import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; + +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_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; + +export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; + +export const loadPending = timeline => ({ + type: TIMELINE_LOAD_PENDING, + timeline, +}); + +export function updateTimeline(timeline, status, accept) { + return (dispatch, getState) => { + if (typeof accept === 'function' && !accept(status)) { + return; + } + + if (getState().getIn(['timelines', timeline, 'isPartial'])) { + // Prevent new items from being added to a partial timeline, + // since it will be reloaded anyway + + return; + } + + const filters = getFiltersRegex(getState(), { contextType: timeline }); + const dropRegex = filters[0]; + const regex = filters[1]; + const text = searchTextFromRawStatus(status); + let filtered = false; + + if (status.account.id !== me) { + filtered = (dropRegex && dropRegex.test(text)) || (regex && regex.test(text)); + } + + dispatch(importFetchedStatus(status)); + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + status, + usePendingItems: preferPendingItems, + filtered + }); + + if (timeline === 'home') { + dispatch(submitMarkers()); + } + }; +}; + +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')); + 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 + timeline.get('pendingItems', ImmutableList()).size) > 0) { + const a = timeline.getIn(['pendingItems', 0]); + const b = timeline.getIn(['items', 0]); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } + } + + 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.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); + + if (timelineId === 'home') { + dispatch(submitMarkers()); + } + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + }).finally(() => { + done(); + }); + }; +}; + +export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, 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, local } = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + local: local, + }, done); +}; + +export function expandTimelineRequest(timeline, isLoadingMore) { + return { + type: TIMELINE_EXPAND_REQUEST, + timeline, + skipLoading: !isLoadingMore, + }; +}; + +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { + return { + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + partial, + isLoadingRecent, + usePendingItems, + skipLoading: !isLoadingMore, + }; +}; + +export function expandTimelineFail(timeline, error, isLoadingMore) { + return { + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + skipLoading: !isLoadingMore, + skipNotFound: timeline.startsWith('account:'), + }; +}; + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top, + }; +}; + +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline, + }; +}; + +export const disconnectTimeline = timeline => ({ + type: TIMELINE_DISCONNECT, + timeline, + usePendingItems: preferPendingItems, +}); + +export const markAsPartial = timeline => ({ + type: TIMELINE_MARK_AS_PARTIAL, + timeline, +}); diff --git a/app/javascript/flavours/glitch/actions/trends.js b/app/javascript/flavours/glitch/actions/trends.js new file mode 100644 index 000000000..1b0ce2b5b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/trends.js @@ -0,0 +1,32 @@ +import api from 'flavours/glitch/util/api'; + +export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +export const fetchTrends = () => (dispatch, getState) => { + dispatch(fetchTrendsRequest()); + + api(getState) + .get('/api/v1/trends') + .then(({ data }) => dispatch(fetchTrendsSuccess(data))) + .catch(err => dispatch(fetchTrendsFail(err))); +}; + +export const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendsSuccess = trends => ({ + type: TRENDS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendsFail = error => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); diff --git a/app/javascript/flavours/glitch/blurhash.js b/app/javascript/flavours/glitch/blurhash.js new file mode 100644 index 000000000..5adcc3e77 --- /dev/null +++ b/app/javascript/flavours/glitch/blurhash.js @@ -0,0 +1,112 @@ +const DIGIT_CHARACTERS = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', +]; + +export const decode83 = (str) => { + let value = 0; + let c, digit; + + for (let i = 0; i < str.length; i++) { + c = str[i]; + digit = DIGIT_CHARACTERS.indexOf(c); + value = value * 83 + digit; + } + + return value; +}; + +export const intToRGB = int => ({ + r: Math.max(0, (int >> 16)), + g: Math.max(0, (int >> 8) & 255), + b: Math.max(0, (int & 255)), +}); + +export const getAverageFromBlurhash = blurhash => { + if (!blurhash) { + return null; + } + + return intToRGB(decode83(blurhash.slice(2, 6))); +}; diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js new file mode 100644 index 000000000..20313535b --- /dev/null +++ b/app/javascript/flavours/glitch/components/account.js @@ -0,0 +1,162 @@ +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'; +import RelativeTimestamp from './relative_timestamp'; + +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' }, +}); + +export default @injectIntl +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) { + if (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} />; + } + } + + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; + } + + 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> + {mute_expires_at} + <DisplayName account={account} /> + </Permalink> + {buttons ? + <div className='account__relationship'> + {buttons} + </div> + : null} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js new file mode 100644 index 000000000..3cc5173dd --- /dev/null +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedNumber } from 'react-intl'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; +import { reduceMotion } from 'flavours/glitch/util/initial_state'; + +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +export default class AnimatedNumber extends React.PureComponent { + + static propTypes = { + value: PropTypes.number.isRequired, + obfuscate: PropTypes.bool, + }; + + state = { + direction: 1, + }; + + componentWillReceiveProps (nextProps) { + if (nextProps.value > this.props.value) { + this.setState({ direction: 1 }); + } else if (nextProps.value < this.props.value) { + this.setState({ direction: -1 }); + } + } + + willEnter = () => { + const { direction } = this.state; + + return { y: -1 * direction }; + } + + willLeave = () => { + const { direction } = this.state; + + return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) }; + } + + render () { + const { value, obfuscate } = this.props; + const { direction } = this.state; + + if (reduceMotion) { + return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />; + } + + const styles = [{ + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <span className='animated-number'> + {items.map(({ key, data, style }) => ( + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span> + ))} + </span> + )} + </TransitionMotion> + ); + } + +} 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..68d8d29c7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/attachment_list.js @@ -0,0 +1,58 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; + +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 noreferrer'><Icon id='link' /> {filename(displayUrl)}</a> + </li> + ); + })} + </ul> + </div> + ); + } + + return ( + <div className='attachment-list'> + <div className='attachment-list__icon'> + <Icon id='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 noreferrer'>{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..d04c1eb68 --- /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'; + +import { assetHost } from 'flavours/glitch/util/config'; + +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_hashtag.js b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js new file mode 100644 index 000000000..d787ed07a --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ShortNumber from 'flavours/glitch/components/short_number'; +import { FormattedMessage } from 'react-intl'; + +export default class AutosuggestHashtag extends React.PureComponent { + + static propTypes = { + tag: PropTypes.shape({ + name: PropTypes.string.isRequired, + url: PropTypes.string, + history: PropTypes.array, + }).isRequired, + }; + + render() { + const { tag } = this.props; + const weeklyUses = tag.history && ( + <ShortNumber + value={tag.history.reduce((total, day) => total + day.uses * 1, 0)} + /> + ); + + return ( + <div className='autosuggest-hashtag'> + <div className='autosuggest-hashtag__name'> + #<strong>{tag.name}</strong> + </div> + {tag.history !== undefined && ( + <div className='autosuggest-hashtag__uses'> + <FormattedMessage + id='autosuggest_hashtag.per_week' + defaultMessage='{count} per week' + values={{ count: weeklyUses }} + /> + </div> + )} + </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..b40a2ff35 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_input.js @@ -0,0 +1,223 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; + +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.arrayOf(PropTypes.string), + maxLength: PropTypes.number, + }; + + static defaultProps = { + autoFocus: true, + searchTokens: ['@', ':', '#'], + }; + + 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 (suggestion.type === 'emoji') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion.type ==='hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; + } + + 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; + + 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} + dir='auto' + 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..967c593af --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -0,0 +1,233 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +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 = (e) => { + this.setState({ focused: true }); + if (this.props.onFocus) { + this.props.onFocus(e); + } + } + + 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 (suggestion.type === 'emoji') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion.type === 'hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; + } + + 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, children } = this.props; + const { suggestionsHidden } = this.state; + + return [ + <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> + <div className='autosuggest-textarea'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <Textarea + ref={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} + dir='auto' + aria-autocomplete='list' + /> + </label> + </div> + {children} + </div>, + + <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'> + <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_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js new file mode 100644 index 000000000..125b51c44 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_composite.js @@ -0,0 +1,110 @@ +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 AvatarComposite extends React.PureComponent { + + static propTypes = { + accounts: ImmutablePropTypes.list.isRequired, + animate: PropTypes.bool, + size: PropTypes.number.isRequired, + }; + + static defaultProps = { + animate: autoPlayGif, + }; + + renderItem (account, size, index) { + const { animate } = 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 = '1px'; + } else { + left = '1px'; + } + } else if (size === 3) { + if (index === 0) { + right = '1px'; + } else if (index > 0) { + left = '1px'; + } + + if (index === 1) { + bottom = '1px'; + } else if (index > 1) { + top = '1px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '1px'; + } + + if (index === 1 || index === 3) { + left = '1px'; + } + + if (index < 2) { + bottom = '1px'; + } else { + top = '1px'; + } + } + + const style = { + left: left, + top: top, + right: right, + bottom: bottom, + width: `${width}%`, + height: `${height}%`, + backgroundSize: 'cover', + backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + return ( + <a + href={account.get('url')} + target='_blank' + onClick={(e) => this.props.onAccountClick(account.get('id'), e)} + title={`@${account.get('acct')}`} + key={account.get('id')} + > + <div style={style} data-avatar-of={`@${account.get('acct')}`} /> + </a> + ); + } + + render() { + const { accounts, size } = this.props; + + return ( + <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}> + {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))} + + {accounts.size > 4 && ( + <span className='account__avatar-composite__label'> + +{accounts.size - 4} + </span> + )} + </div> + ); + } + +} 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/blurhash.js b/app/javascript/flavours/glitch/components/blurhash.js new file mode 100644 index 000000000..2af5cfc56 --- /dev/null +++ b/app/javascript/flavours/glitch/components/blurhash.js @@ -0,0 +1,65 @@ +// @ts-check + +import { decode } from 'blurhash'; +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +/** + * @typedef BlurhashPropsBase + * @property {string?} hash Hash to render + * @property {number} width + * Width of the blurred region in pixels. Defaults to 32 + * @property {number} [height] + * Height of the blurred region in pixels. Defaults to width + * @property {boolean} [dummy] + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched + */ + +/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ + +/** + * Component that is used to render blurred of blurhash string + * + * @param {BlurhashProps} param1 Props of the component + * @returns Canvas which will render blurred region element to embed + */ +function Blurhash({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) { + const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef()); + + useEffect(() => { + const { current: canvas } = canvasRef; + canvas.width = canvas.width; // resets canvas + + if (dummy || !hash) return; + + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } + }, [dummy, hash, width, height]); + + return ( + <canvas {...canvasProps} ref={canvasRef} width={width} height={height} /> + ); +} + +Blurhash.propTypes = { + hash: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + dummy: PropTypes.bool, +}; + +export default React.memo(Blurhash); diff --git a/app/javascript/flavours/glitch/components/button.js b/app/javascript/flavours/glitch/components/button.js new file mode 100644 index 000000000..b1815c3e1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/button.js @@ -0,0 +1,52 @@ +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, + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node, + }; + + 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, + }; + + 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..c9da7d329 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { supportsPassiveEvents } 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, + bindToDocument: PropTypes.bool, + }; + + scrollTop () { + const scrollable = this.props.bindToDocument ? document.scrollingElement : 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 () { + if (this.props.bindToDocument) { + document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); + } else { + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); + } + } + + componentWillUnmount () { + if (this.props.bindToDocument) { + document.removeEventListener('wheel', this.handleWheel); + } else { + 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..05688f867 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { createPortal } from 'react-dom'; + +export default class ColumnBackButton extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + multiColumn: PropTypes.bool, + }; + + 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 () { + const { multiColumn } = this.props; + + const component = ( + <button onClick={this.handleClick} className='column-back-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + + if (multiColumn) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } + } + +} 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..faa0c23a8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button_slim.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; + +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'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <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..ccd0714f1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -0,0 +1,220 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +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' }, +}); + +export default @injectIntl +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, + multiColumn: PropTypes.bool, + extraButton: PropTypes.node, + showBackButton: PropTypes.bool, + children: PropTypes.node, + pinned: PropTypes.bool, + placeholder: PropTypes.bool, + onPin: PropTypes.func, + onMove: PropTypes.func, + onClick: PropTypes.func, + appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, + }; + + state = { + collapsed: true, + animating: 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 }); + } + + handlePin = () => { + if (!this.props.pinned) { + this.historyBack(); + } + this.props.onPin(); + } + + render () { + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; + const { collapsed, animating } = this.state; + + 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, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + 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}><Icon id='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}><Icon id='chevron-left' /></button> + <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button> + </div> + ); + } else if (multiColumn && this.props.onPin) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + } + + if (!pinned && (multiColumn || showBackButton)) { + backButton = ( + <button onClick={this.handleBackClick} className='column-header__back-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || (multiColumn && this.props.onPin)) { + 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='icon-with-badge'> + <Icon id='sliders' /> + {collapseIssues && <i className='icon-with-badge__issue-badge' />} + </i> + </button> + ); + } + + const hasTitle = icon && title; + + const component = ( + <div className={wrapperClassName}> + <h1 className={buttonClassName}> + {hasTitle && ( + <button onClick={this.handleTitleClick}> + <Icon id={icon} fixedWidth className='column-header__icon' /> + {title} + </button> + )} + + {!hasTitle && backButton} + + <div className='column-header__buttons'> + {hasTitle && backButton} + {extraButton} + {collapseButton} + </div> + </h1> + + <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> + <div className='column-header__collapsible-inner'> + {(!collapsed || animating) && collapsedContent} + </div> + </div> + + {appendContent} + </div> + ); + + if (multiColumn || placeholder) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } + } + +} diff --git a/app/javascript/flavours/glitch/components/common_counter.js b/app/javascript/flavours/glitch/components/common_counter.js new file mode 100644 index 000000000..e10cd9b76 --- /dev/null +++ b/app/javascript/flavours/glitch/components/common_counter.js @@ -0,0 +1,62 @@ +// @ts-check +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +/** + * Returns custom renderer for one of the common counter types + * + * @param {"statuses" | "following" | "followers"} counterType + * Type of the counter + * @param {boolean} isBold Whether display number must be displayed in bold + * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + * Renderer function + * @throws If counterType is not covered by this function + */ +export function counterRenderer(counterType, isBold = true) { + /** + * @type {(displayNumber: JSX.Element) => JSX.Element} + */ + const renderCounter = isBold + ? (displayNumber) => <strong>{displayNumber}</strong> + : (displayNumber) => displayNumber; + + switch (counterType) { + case 'statuses': { + return (displayNumber, pluralReady) => ( + <FormattedMessage + id='account.statuses_counter' + defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}' + values={{ + count: pluralReady, + counter: renderCounter(displayNumber), + }} + /> + ); + } + case 'following': { + return (displayNumber, pluralReady) => ( + <FormattedMessage + id='account.following_counter' + defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}' + values={{ + count: pluralReady, + counter: renderCounter(displayNumber), + }} + /> + ); + } + case 'followers': { + return (displayNumber, pluralReady) => ( + <FormattedMessage + id='account.followers_counter' + defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}' + values={{ + count: pluralReady, + counter: renderCounter(displayNumber), + }} + /> + ); + } + default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); + } +} 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..ad978a2c6 --- /dev/null +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -0,0 +1,97 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class DisplayName extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + className: PropTypes.string, + inline: PropTypes.bool, + localDomain: PropTypes.string, + others: ImmutablePropTypes.list, + handleClick: PropTypes.func, + }; + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + } + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + } + + render() { + const { account, className, inline, localDomain, others, onAccountClick } = this.props; + + const computedClass = classNames('display-name', { inline }, className); + + if (!account) return null; + + let displayName, suffix; + + let acct = account.get('acct'); + + if (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; + } + + if (others && others.size > 0) { + displayName = others.take(2).map(a => ( + <a + href={a.get('url')} + target='_blank' + onClick={(e) => onAccountClick(a.get('id'), e)} + title={`@${a.get('acct')}`} + rel='noopener noreferrer' + > + <bdi key={a.get('id')}> + <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /> + </bdi> + </a> + )).reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + displayName.push(` +${others.size - 2}`); + } + + suffix = ( + <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)} rel='noopener noreferrer'> + <span className='display-name__account'>@{acct}</span> + </a> + ); + } else { + displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; + suffix = <span className='display-name__account'>@{acct}</span>; + } + + return ( + <span className={computedClass} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + {displayName} + {inline ? ' ' : null} + {suffix} + </span> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/domain.js b/app/javascript/flavours/glitch/components/domain.js new file mode 100644 index 000000000..697065d87 --- /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: 'Unblock domain {domain}' }, +}); + +export default @injectIntl +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..023fecb9a --- /dev/null +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -0,0 +1,293 @@ +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 { supportsPassiveEvents } from 'detect-passive-events'; + +const listenerOptions = supportsPassiveEvents ? { 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({ preventScroll: true }); + } + 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 = null; + + switch(e.key) { + case 'ArrowDown': + element = items[index+1] || items[0]; + break; + case 'ArrowUp': + element = items[index-1] || items[items.length-1]; + break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + break; + case 'Home': + element = items[0]; + break; + case 'End': + element = items[items.length-1]; + break; + case 'Escape': + this.props.onClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleItemKeyPress = e => { + if (e.key === 'Enter' || e.key === ' ') { + 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 noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} 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, + title: PropTypes.string, + disabled: PropTypes.bool, + status: ImmutablePropTypes.map, + isUserTouching: PropTypes.func, + onOpen: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + dropdownPlacement: PropTypes.string, + openDropdownId: PropTypes.number, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + title: '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 = () => { + if (this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + this.activeElement = null; + } + this.props.onClose(this.state.id); + } + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleClick(e); + e.stopPropagation(); + e.preventDefault(); + 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, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props; + const open = this.state.id === openDropdownId; + + return ( + <div> + <IconButton + icon={icon} + title={title} + active={open} + disabled={disabled} + size={size} + ref={this.setTargetRef} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} + /> + + <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..8e6cd1461 --- /dev/null +++ b/app/javascript/flavours/glitch/components/error_boundary.js @@ -0,0 +1,123 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { preferencesLink } from 'flavours/glitch/util/backend_links'; +import StackTrace from 'stacktrace-js'; + +export default class ErrorBoundary extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + }; + + state = { + hasError: false, + errorMessage: undefined, + stackTrace: undefined, + mappedStackTrace: undefined, + componentStack: undefined, + } + + componentDidCatch(error, info) { + this.setState({ + hasError: true, + errorMessage: error.toString(), + stackTrace: error.stack, + componentStack: info && info.componentStack, + mappedStackTrace: undefined, + }); + + StackTrace.fromError(error).then((stackframes) => { + this.setState({ + mappedStackTrace: stackframes.map((sf) => sf.toString()).join('\n'), + }); + }).catch(() => { + this.setState({ + mappedStackTrace: undefined, + }); + }); + } + + handleReload(e) { + e.preventDefault(); + window.location.reload(); + } + + render() { + const { hasError, errorMessage, stackTrace, mappedStackTrace, componentStack } = this.state; + + if (!hasError) return this.props.children; + + const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError'); + + let debugInfo = ''; + if (stackTrace) { + debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```'; + } + if (mappedStackTrace) { + debugInfo += 'Mapped stack trace\n-----------\n\n```\n' + errorMessage + '\n' + mappedStackTrace.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:' /> + </p> + <ul> + { likelyBrowserAddonIssue && ( + <li> + <FormattedMessage + id='web_app_crash.disable_addons' + defaultMessage='Disable browser add-ons or built-in translation tools' + /> + </li> + ) } + <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 noreferrer' 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> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/gifv.js b/app/javascript/flavours/glitch/components/gifv.js new file mode 100644 index 000000000..b775e5200 --- /dev/null +++ b/app/javascript/flavours/glitch/components/gifv.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class GIFV extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + }; + + state = { + loading: true, + }; + + handleLoadedData = () => { + this.setState({ loading: false }); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.src !== this.props.src) { + this.setState({ loading: true }); + } + } + + handleClick = e => { + const { onClick } = this.props; + + if (onClick) { + e.stopPropagation(); + onClick(); + } + } + + render () { + const { src, width, height, alt } = this.props; + const { loading } = this.state; + + return ( + <div className='gifv' style={{ position: 'relative' }}> + {loading && ( + <canvas + width={width} + height={height} + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + onClick={this.handleClick} + /> + )} + + <video + src={src} + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + muted + loop + autoPlay + playsInline + onClick={this.handleClick} + onLoadedData={this.handleLoadedData} + style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} + /> + </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..24c595ed7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hashtag.js @@ -0,0 +1,100 @@ +// @ts-check +import React from 'react'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from './permalink'; +import ShortNumber from 'flavours/glitch/components/short_number'; + +class SilentErrorBoundary extends React.Component { + + static propTypes = { + children: PropTypes.node, + }; + + state = { + error: false, + }; + + componentDidCatch () { + this.setState({ error: true }); + } + + render () { + if (this.state.error) { + return null; + } + + return this.props.children; + } + +} + +/** + * Used to render counter of how much people are talking about hashtag + * + * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + */ +const accountsCountRenderer = (displayNumber, pluralReady) => ( + <FormattedMessage + id='trends.counter_by_accounts' + defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking' + values={{ + count: pluralReady, + counter: <strong>{displayNumber}</strong>, + }} + /> +); + +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> + + <ShortNumber + value={ + hashtag.getIn(['history', 0, 'accounts']) * 1 + + hashtag.getIn(['history', 1, 'accounts']) * 1 + } + renderer={accountsCountRenderer} + /> + </div> + + <div className='trends__item__current'> + <ShortNumber + value={ + hashtag.getIn(['history', 0, 'uses']) * 1 + + hashtag.getIn(['history', 1, 'uses']) * 1 + } + /> + </div> + + <div className='trends__item__sparkline'> + <SilentErrorBoundary> + <Sparklines + width={50} + height={28} + data={hashtag + .get('history') + .reverse() + .map((day) => day.get('uses')) + .toArray()} + > + <SparklinesCurve style={{ fill: 'none' }} /> + </Sparklines> + </SilentErrorBoundary> + </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..d8a17722f --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Icon extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + className: PropTypes.string, + fixedWidth: PropTypes.bool, + }; + + render () { + const { id, className, fixedWidth, ...other } = this.props; + + return ( + <i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} /> + ); + } + +} 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..58d3568dd --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -0,0 +1,154 @@ +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'; +import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; + +export default class IconButton extends React.PureComponent { + + static propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: 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, + overlay: PropTypes.bool, + tabIndex: PropTypes.string, + label: PropTypes.string, + counter: PropTypes.number, + obfuscateCount: PropTypes.bool, + }; + + static defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: '0', + }; + + state = { + activate: false, + deactivate: false, + } + + componentWillReceiveProps (nextProps) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + + handleClick = (e) => { + e.preventDefault(); + + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + } + + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + } + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(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, + className, + disabled, + expanded, + icon, + inverted, + overlay, + pressed, + tabIndex, + title, + counter, + obfuscateCount, + } = this.props; + + const { + activate, + deactivate, + } = this.state; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + activate, + deactivate, + overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', + }); + + if (typeof counter !== 'undefined') { + style.width = 'auto'; + } + + return ( + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} + style={style} + tabIndex={tabIndex} + disabled={disabled} + > + <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>} + {this.props.label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js new file mode 100644 index 000000000..a42ba4589 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_with_badge.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; + +const formatNumber = num => num > 40 ? '40+' : num; + +const IconWithBadge = ({ id, count, issueBadge, className }) => ( + <i className='icon-with-badge'> + <Icon id={id} fixedWidth className={className} /> + {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} + {issueBadge && <i className='icon-with-badge__issue-badge' />} + </i> +); + +IconWithBadge.propTypes = { + id: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, + className: PropTypes.string, +}; + +export default IconWithBadge; 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..88f29892e --- /dev/null +++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; +import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry'; + +// Diff these props in the "unrendered" state +const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; + +export default class IntersectionObserverArticle extends React.Component { + + 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) { + const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); + const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); + if (!!isUnrendered !== !!willBeUnrendered) { + // If we're going from rendered to unrendered (or vice versa) then update + return true; + } + // If we are and remain hidden, diff based on props + if (isUnrendered) { + return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]); + } + // Else, assume the children have changed + return true; + } + + + 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..fe3f60a58 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_gap.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +export default @injectIntl +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)}> + <Icon id='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/load_pending.js b/app/javascript/flavours/glitch/components/load_pending.js new file mode 100644 index 000000000..7e2702403 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_pending.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + } + + render() { + const { count } = this.props; + + return ( + <button className='load-more load-gap' onClick={this.props.onClick}> + <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} /> + </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/logo.js b/app/javascript/flavours/glitch/components/logo.js new file mode 100644 index 000000000..d1c7f08a9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/logo.js @@ -0,0 +1,9 @@ +import React from 'react'; + +const Logo = () => ( + <svg viewBox='0 0 216.4144 232.00976' className='logo'> + <use xlinkHref='#mastodon-svg-logo' /> + </svg> +); + +export default Logo; 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..68195ea80 --- /dev/null +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -0,0 +1,404 @@ +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, useBlurhash } from 'flavours/glitch/util/initial_state'; +import { debounce } from 'lodash'; +import Blurhash from 'flavours/glitch/components/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: '{number, plural, one {Hide image} other {Hide images}}', + 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, + autoplay: PropTypes.bool, + }; + + 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; + } + } + + getAutoPlay() { + return this.props.autoplay || autoPlayGif; + } + + hoverToPlay () { + const { attachment } = this.props; + return !this.getAutoPlay() && 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(); + } + + 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') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'> + <Blurhash + hash={attachment.get('blurhash')} + className='media-gallery__preview' + dummy={!useBlurhash} + /> + </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' + rel='noopener noreferrer' + > + <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() && this.getAutoPlay(); + + 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}%` }}> + <Blurhash + hash={attachment.get('blurhash')} + dummy={!useBlurhash} + className={classNames('media-gallery__preview', { + 'media-gallery__preview--hidden': visible && this.state.loaded, + })} + /> + {visible && thumbnail} + </div> + ); + } + +} + +export default @injectIntl +class MediaGallery extends React.PureComponent { + + static propTypes = { + sensitive: 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, + visible: PropTypes.bool, + autoplay: PropTypes.bool, + onToggleVisibility: PropTypes.func, + }; + + static defaultProps = { + standalone: false, + }; + + state = { + visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), + width: this.props.defaultWidth, + }; + + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + componentWillReceiveProps (nextProps) { + if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) { + this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' }); + } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ visible: nextProps.visible }); + } + } + + componentDidUpdate (prevProps) { + if (this.node) { + this.handleResize(); + } + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + leading: true, + trailing: true, + }); + + handleOpen = () => { + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ visible: !this.state.visible }); + } + } + + handleClick = (index) => { + this.props.onOpenMedia(this.props.media, index); + } + + handleRef = (node) => { + this.node = node; + + if (this.node) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.node.offsetWidth; + + if (width && width != this.state.width) { + // offsetWidth triggers a layout, so only calculate when we need to + if (this.props.cacheWidth) { + this.props.cacheWidth(width); + } + + this.setState({ + width: width, + }); + } + } + + 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, autoplay } = this.props; + const { visible } = this.state; + const size = media.take(4).size; + const uncached = media.every(attachment => attachment.get('type') === 'unknown'); + + 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 autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />); + } + + if (uncached) { + spoilerButton = ( + <button type='button' disabled className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span> + </button> + ); + } else if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} 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 && !uncached, 'spoiler-button--click-thru': uncached })}> + {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..ee5bf7c1e --- /dev/null +++ b/app/javascript/flavours/glitch/components/missing_indicator.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import illustration from 'flavours/glitch/images/elephant_ui_disappointed.svg'; +import classNames from 'classnames'; + +const MissingIndicator = ({ fullPage }) => ( + <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> + + <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> +); + +MissingIndicator.propTypes = { + fullPage: PropTypes.bool, +}; + +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..913234d32 --- /dev/null +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -0,0 +1,143 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import 'wicg-inert'; +import { createBrowserHistory } from 'history'; +import { multiply } from 'color-blend'; + +export default class ModalRoot extends React.PureComponent { + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + children: PropTypes.node, + onClose: PropTypes.func.isRequired, + backgroundColor: PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number, + }), + noEsc: PropTypes.bool, + }; + + activeElement = this.props.children ? document.activeElement : null; + + handleKeyUp = (e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.children && !this.props.noEsc) { + this.props.onClose(); + } + } + + handleKeyDown = (e) => { + if (e.key === 'Tab') { + const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + element.focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + this.history = this.context.router ? this.context.router.history : createBrowserHistory(); + } + + componentWillReceiveProps (nextProps) { + if (!!nextProps.children && !this.props.children) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } + } + + componentDidUpdate (prevProps) { + if (!this.props.children && !!prevProps.children) { + this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + + // Because of the wicg-inert polyfill, the activeElement may not be + // immediately selectable, we have to wait for observers to run, as + // described in https://github.com/WICG/inert#performance-and-gotchas + Promise.resolve().then(() => { + this.activeElement.focus({ preventScroll: true }); + this.activeElement = null; + }).catch(console.error); + + this.handleModalClose(); + } + if (this.props.children && !prevProps.children) { + this.handleModalOpen(); + } + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('keydown', this.handleKeyDown); + } + + 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 visible = !!children; + + if (!visible) { + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> + ); + } + + let backgroundColor = null; + + if (this.props.backgroundColor) { + backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); + } + + return ( + <div className='modal-root' ref={this.setRef}> + <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> + <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} /> + <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..3c7d67109 --- /dev/null +++ b/app/javascript/flavours/glitch/components/notification_purge_buttons.js @@ -0,0 +1,59 @@ +/** + * 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'; +import Icon from 'flavours/glitch/components/icon'; + +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' }, +}); + +export default @injectIntl +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}> + <Icon id='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/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js new file mode 100644 index 000000000..01dce0a38 --- /dev/null +++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; +import { FormattedMessage } from 'react-intl'; + +export default @connect() +class PictureInPicturePlaceholder extends React.PureComponent { + + static propTypes = { + width: PropTypes.number, + dispatch: PropTypes.func.isRequired, + }; + + state = { + width: this.props.width, + height: this.props.width && (this.props.width / (16/9)), + }; + + handleClick = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + setRef = c => { + this.node = c; + + if (this.node) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.node.offsetWidth; + const height = width / (16/9); + + this.setState({ width, height }); + } + + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + render () { + const { height } = this.state; + + return ( + <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}> + <Icon id='window-restore' /> + <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js new file mode 100644 index 000000000..f230823cc --- /dev/null +++ b/app/javascript/flavours/glitch/components/poll.js @@ -0,0 +1,212 @@ +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 Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'flavours/glitch/util/emoji'; +import RelativeTimestamp from './relative_timestamp'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, + voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' }, +}); + +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, + disabled: PropTypes.bool, + refresh: PropTypes.func, + onVote: PropTypes.func, + }; + + state = { + selected: {}, + expired: null, + }; + + static getDerivedStateFromProps (props, state) { + const { poll, intl } = props; + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); + return (expired === state.expired) ? null : { expired }; + } + + componentDidMount () { + this._setupTimer(); + } + + componentDidUpdate () { + this._setupTimer(); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _setupTimer () { + const { poll, intl } = this.props; + clearTimeout(this._timer); + if (!this.state.expired) { + const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now(); + this._timer = setTimeout(() => { + this.setState({ expired: true }); + }, delay); + } + } + + _toggleOption = value => { + 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 }); + } + } + + handleOptionChange = ({ target: { value } }) => { + this._toggleOption(value); + }; + + handleOptionKeyPress = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + this._toggleOption(e.target.getAttribute('data-index')); + e.stopPropagation(); + e.preventDefault(); + } + } + + handleVote = () => { + if (this.props.disabled) { + return; + } + + this.props.onVote(Object.keys(this.state.selected)); + }; + + handleRefresh = () => { + if (this.props.disabled) { + return; + } + + this.props.refresh(); + }; + + renderOption (option, optionIndex, showResults) { + const { poll, disabled, intl } = this.props; + const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); + const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 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 voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); + + 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')}> + <label className={classNames('poll__option', { 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 })} + tabIndex='0' + role={poll.get('multiple') ? 'checkbox' : 'radio'} + onKeyPress={this.handleOptionKeyPress} + aria-checked={active} + aria-label={option.get('title')} + data-index={optionIndex} + /> + )} + {showResults && <span className='poll__number'> + {Math.round(percent)}% + </span>} + + <span + className='poll__option__text translate' + dangerouslySetInnerHTML={{ __html: titleEmojified }} + /> + + {!!voted && <span className='poll__voted'> + <Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} /> + </span>} + </label> + + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + </li> + ); + } + + render () { + const { poll, intl } = this.props; + const { expired } = this.state; + + if (!poll) { + return null; + } + + const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; + const showResults = poll.get('voted') || expired; + const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + + let votesCount = null; + + if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { + votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />; + } else { + votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />; + } + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} + </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>} + {votesCount} + {poll.get('expires_at') && <span> · {timeRemaining}</span>} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/radio_button.js b/app/javascript/flavours/glitch/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/flavours/glitch/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + <label className='radio-button'> + <input + name={name} + type='radio' + value={value} + checked={checked} + onChange={onChange} + /> + + <span className={classNames('radio-button__input', { checked })} /> + + <span>{label}</span> + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/regeneration_indicator.js b/app/javascript/flavours/glitch/components/regeneration_indicator.js new file mode 100644 index 000000000..68ce09df9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/regeneration_indicator.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; + +const RegenerationIndicator = () => ( + <div className='regeneration-indicator'> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> + + <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> +); + +export default RegenerationIndicator; 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..711181dcd --- /dev/null +++ b/app/javascript/flavours/glitch/components/relative_timestamp.js @@ -0,0 +1,192 @@ +import React from 'react'; +import { injectIntl, defineMessages } from 'react-intl'; +import PropTypes from 'prop-types'; + +const messages = defineMessages({ + today: { id: 'relative_time.today', defaultMessage: 'today' }, + 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, timeGiven = true) => { + const delta = now - date.getTime(); + + let relativeTime; + + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else 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, timeGiven = true) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else 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 timeGiven = timestamp.includes('T'); + const date = new Date(timestamp); + const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven); + + 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..cc8d9f1f3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -0,0 +1,360 @@ +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 LoadPending from './load_pending'; +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'; +import { connect } from 'react-redux'; + +const MOUSE_IDLE_DELAY = 300; + +const mapStateToProps = (state, { scrollKey }) => { + return { + preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']), + }; +}; + +export default @connect(mapStateToProps, null, null, { forwardRef: true }) +class ScrollableList extends PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onLoadMore: PropTypes.func, + onLoadPending: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + showLoading: PropTypes.bool, + hasMore: PropTypes.bool, + numPending: PropTypes.number, + prepend: PropTypes.node, + append: PropTypes.node, + alwaysPrepend: PropTypes.bool, + emptyMessage: PropTypes.node, + children: PropTypes.node, + bindToDocument: PropTypes.bool, + preventScroll: PropTypes.bool, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + fullscreen: null, + cachedMediaWidth: 300, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const scrollTop = this.getScrollTop(); + const scrollHeight = this.getScrollHeight(); + const clientHeight = this.getClientHeight(); + 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.getScrollTop() !== newScrollTop) { + this.lastScrollWasSynthetic = true; + + if (this.props.bindToDocument) { + document.scrollingElement.scrollTop = newScrollTop; + } else { + 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.getScrollTop() === 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.props.preventScroll) { + 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.getScrollTop() > 0 || this.mouseMovedRecently)) { + return { height: this.getScrollHeight(), top: this.getScrollTop() }; + } else { + return null; + } + } + + getScrollTop = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollTop : this.node.scrollTop; + } + + getScrollHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollHeight : this.node.scrollHeight; + } + + getClientHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.clientHeight : this.node.clientHeight; + } + + updateScrollBottom = (snapshot) => { + const newScrollTop = this.getScrollHeight() - 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); + const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0); + + if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) { + return this.getScrollHeight() - this.getScrollTop(); + } 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 () { + if (this.props.bindToDocument) { + document.addEventListener('scroll', this.handleScroll); + document.addEventListener('wheel', this.handleWheel); + } else { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); + } + } + + detachScrollListener () { + if (this.props.bindToDocument) { + document.removeEventListener('scroll', this.handleScroll); + document.removeEventListener('wheel', this.handleWheel); + } else { + 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); + } + + handleLoadPending = e => { + e.preventDefault(); + this.props.onLoadPending(); + // Prevent the weird scroll-jumping behavior, as we explicitly don't want to + // scroll to top, and we know the scroll height is going to change + this.scrollToTopOnMouseIdle = false; + this.lastScrollWasSynthetic = false; + this.clearMouseIdleTimer(); + this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + this.mouseMovedRecently = true; + } + + render () { + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, 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; + const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : 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} + + {loadPending} + + {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} + + {!hasMore && append} + </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/short_number.js b/app/javascript/flavours/glitch/components/short_number.js new file mode 100644 index 000000000..e4ba09634 --- /dev/null +++ b/app/javascript/flavours/glitch/components/short_number.js @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../util/numbers'; +import { FormattedMessage, FormattedNumber } from 'react-intl'; +// @ts-check + +/** + * @callback ShortNumberRenderer + * @param {JSX.Element} displayNumber Number to display + * @param {number} pluralReady Number used for pluralization + * @returns {JSX.Element} Final render of number + */ + +/** + * @typedef {object} ShortNumberProps + * @property {number} value Number to display in short variant + * @property {ShortNumberRenderer} [renderer] + * Custom renderer for numbers, provided as a prop. If another renderer + * passed as a child of this component, this prop won't be used. + * @property {ShortNumberRenderer} [children] + * Custom renderer for numbers, provided as a child. If another renderer + * passed as a prop of this component, this one will be used instead. + */ + +/** + * Component that renders short big number to a shorter version + * + * @param {ShortNumberProps} param0 Props for the component + * @returns {JSX.Element} Rendered number + */ +function ShortNumber({ value, renderer, children }) { + const shortNumber = toShortNumber(value); + const [, division] = shortNumber; + + // eslint-disable-next-line eqeqeq + if (children != null && renderer != null) { + console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); + } + + // eslint-disable-next-line eqeqeq + const customRenderer = children != null ? children : renderer; + + const displayNumber = <ShortNumberCounter value={shortNumber} />; + + // eslint-disable-next-line eqeqeq + return customRenderer != null + ? customRenderer(displayNumber, pluralReady(value, division)) + : displayNumber; +} + +ShortNumber.propTypes = { + value: PropTypes.number.isRequired, + renderer: PropTypes.func, + children: PropTypes.func, +}; + +/** + * @typedef {object} ShortNumberCounterProps + * @property {import('../util/number').ShortNumber} value Short number + */ + +/** + * Renders short number into corresponding localizable react fragment + * + * @param {ShortNumberCounterProps} param0 Props for the component + * @returns {JSX.Element} FormattedMessage ready to be embedded in code + */ +function ShortNumberCounter({ value }) { + const [rawNumber, unit, maxFractionDigits = 0] = value; + + const count = ( + <FormattedNumber + value={rawNumber} + maximumFractionDigits={maxFractionDigits} + /> + ); + + let values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + <FormattedMessage + id='units.short.thousand' + defaultMessage='{count}K' + values={values} + /> + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + <FormattedMessage + id='units.short.million' + defaultMessage='{count}M' + values={values} + /> + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + <FormattedMessage + id='units.short.billion' + defaultMessage='{count}B' + values={values} + /> + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: return count; + } +} + +ShortNumberCounter.propTypes = { + value: PropTypes.arrayOf(PropTypes.number), +}; + +export default React.memo(ShortNumber); diff --git a/app/javascript/flavours/glitch/components/spoilers.js b/app/javascript/flavours/glitch/components/spoilers.js new file mode 100644 index 000000000..8527403c1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/spoilers.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default +class Spoilers extends React.PureComponent { + static propTypes = { + spoilerText: PropTypes.string, + children: PropTypes.node, + }; + + state = { + hidden: true, + } + + handleSpoilerClick = () => { + this.setState({ hidden: !this.state.hidden }); + } + + render () { + const { spoilerText, children } = this.props; + const { hidden } = this.state; + + const toggleText = hidden ? + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + /> : + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />; + + return ([ + <p className='spoiler__text'> + {spoilerText} + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p>, + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + {children} + </div> + ]); + } +} + diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js new file mode 100644 index 000000000..782fd918e --- /dev/null +++ b/app/javascript/flavours/glitch/components/status.js @@ -0,0 +1,795 @@ +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, Audio } 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'; +import { displayMedia } from 'flavours/glitch/util/initial_state'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; + +// 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(', '); +}; + +export const defaultMediaVisibility = (status, settings) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) { + return true; + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +} + +export default @injectIntl +class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + containerId: PropTypes.string, + id: PropTypes.string, + status: ImmutablePropTypes.map, + otherAccounts: ImmutablePropTypes.list, + 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, + unread: 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, + onClick: PropTypes.func, + scrollKey: PropTypes.string, + deployPictureInPicture: PropTypes.func, + usingPiP: PropTypes.bool, + }; + + state = { + isCollapsed: false, + autoCollapsed: false, + isExpanded: undefined, + showMedia: undefined, + statusId: undefined, + revealBehindCW: undefined, + showCard: false, + forceFilter: 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', + 'muted', + 'collapse', + 'notification', + 'hidden', + 'expanded', + 'unread', + 'usingPiP', + ] + + updateOnStates = [ + 'isExpanded', + 'isCollapsed', + 'showMedia', + 'forceFilter', + ] + + // 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; + } + } + + if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); + update.statusId = nextProps.status.get('id'); + updated = true; + } + + if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) { + update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']); + if (update.revealBehindCW) { + update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); + } + 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 }); + } + + // Hack to fix timeline jumps when a preview card is fetched + this.setState({ + showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'), + }); + } + + // Hack to fix timeline jumps on second rendering when auto-collapsing + // or on subsequent rendering when a preview card has been fetched + getSnapshotBeforeUpdate (prevProps, prevState) { + if (!this.props.getScrollPosition) return null; + + const { muted, hidden, status, settings } = this.props; + + const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards'); + if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) { + if (doShowCard) this.setState({ showCard: true }); + if (this.state.autoCollapsed) this.setState({ autoCollapsed: false }); + return this.props.getScrollPosition(); + } else { + return null; + } + } + + componentDidUpdate (prevProps, prevState, snapshot) { + if (snapshot !== null && this.props.updateScrollBottom && 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 (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 if (this.props.onClick) { + this.props.onClick(); + return; + } else { + if (destination === undefined) { + destination = `/statuses/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } + let state = {...router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + router.history.push(destination, state); + } + e.preventDefault(); + } + } + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + + 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 = (options) => { + const { status } = this.props; + this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options); + } + + handleOpenMedia = (media, index) => { + this.props.onOpenMedia(this.props.status.get('id'), media, index); + } + + handleHotkeyOpenMedia = e => { + const { status, onOpenMedia, onOpenVideo } = this.props; + const statusId = status.get('id'); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 }); + } else { + onOpenMedia(statusId, status.get('media_attachments'), 0); + } + } + } + + handleDeployPictureInPicture = (type, mediaProps) => { + const { deployPictureInPicture, status } = this.props; + + deployPictureInPicture(status, type, mediaProps); + } + + 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); + } + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + + handleUnfilterClick = e => { + const { onUnfilter, status } = this.props; + onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false })); + } + + handleFilterClick = () => { + this.setState({ forceFilter: true }); + } + + handleRef = c => { + this.node = c; + } + + renderLoadingMediaGallery () { + return <div className='media-gallery' style={{ height: '110px' }} />; + } + + renderLoadingVideoPlayer () { + return <div className='video-player' style={{ height: '110px' }} />; + } + + renderLoadingAudioPlayer () { + return <div className='audio-player' style={{ height: '110px' }} />; + } + + render () { + const { + handleRef, + parseClick, + setExpansion, + setCollapsed, + } = this; + const { router } = this.context; + const { + intl, + status, + account, + otherAccounts, + settings, + collapsed, + muted, + prepend, + intersectionObserverWrapper, + onOpenVideo, + onOpenMedia, + notification, + hidden, + unread, + featured, + usingPiP, + ...other + } = this.props; + const { isExpanded, isCollapsed, forceFilter } = this.state; + let background = null; + let attachments = null; + let media = null; + let mediaIcon = null; + + if (status === null) { + return null; + } + + 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, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + if (hidden) { + return ( + <HotKeys handlers={handlers}> + <div ref={this.handleRef} className='status focusable' tabIndex='0'> + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {' '} + {status.get('content')} + </div> + </HotKeys> + ); + } + + const filtered = (status.get('filtered') || status.getIn(['reblog', 'filtered'])) && settings.get('filtering_behavior') !== 'content_warning'; + if (forceFilter === undefined ? filtered : forceFilter) { + 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' /> + {settings.get('filtering_behavior') !== 'upstream' && ' '} + {settings.get('filtering_behavior') !== 'upstream' && ( + <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> + <FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' /> + </button> + )} + </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 (usingPiP) { + media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; + mediaIcon = 'video-camera'; + } 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']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > + {Component => ( + <Component + src={attachment.get('url')} + alt={attachment.get('description')} + poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} + backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} + foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} + accentColor={attachment.getIn(['meta', 'colors', 'accent'])} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + width={this.props.cachedMediaWidth} + height={110} + cacheWidth={this.props.cacheMediaWidth} + deployPictureInPicture={this.handleDeployPictureInPicture} + /> + )} + </Bundle> + ); + mediaIcon = 'music'; + } else if (attachments.getIn([0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => (<Component + preview={attachment.get('preview_url')} + frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={attachment.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} + deployPictureInPicture={this.handleDeployPictureInPicture} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + />)} + </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.handleOpenMedia} + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + /> + )} + </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.handleOpenMedia} + card={status.get('card')} + compact + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + sensitive={status.get('sensitive')} + /> + ); + 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', + status: 'posted', + }[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 computedClass = classNames('status', `status-${status.get('visibility')}`, { + collapsed: isCollapsed, + 'has-background': isCollapsed && background, + 'status__wrapper-reply': !!status.get('in_reply_to_id'), + unread, + 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} + otherAccounts={otherAccounts} + /> + ) : null} + </span> + <StatusIcons + status={status} + mediaIcon={mediaIcon} + collapsible={settings.getIn(['collapsed', 'enabled'])} + collapsed={isCollapsed} + setCollapsed={setCollapsed} + directMessage={!!otherAccounts} + /> + </header> + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + expanded={isExpanded} + onExpandedToggle={this.handleExpandedToggle} + parseClick={parseClick} + disabled={!router} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} + /> + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( + <StatusActionBar + {...other} + status={status} + account={status.get('account')} + showReplyCount={settings.get('show_reply_count')} + directMessage={!!otherAccounts} + onFilter={this.handleFilterClick} + /> + ) : 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..74bfd948e --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -0,0 +1,331 @@ +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'; +import classNames from 'classnames'; + +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 with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + 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' }, + hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, +}); + +export default @injectIntl +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, + onFilter: PropTypes.func, + withDismiss: PropTypes.bool, + showReplyCount: PropTypes.bool, + directMessage: PropTypes.bool, + scrollKey: PropTypes.string, + 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}; + if (state.mastodonModalOpen) { + this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 }); + } else { + 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); + } + } + + handleFilterClick = () => { + this.props.onFilter(); + } + + render () { + const { status, intl, withDismiss, showReplyCount, directMessage, scrollKey } = this.props; + + const anonymousAccess = !me; + const mutingConversation = status.get('muted'); + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const writtenByMe = status.getIn(['account', 'id']) === me; + + 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 (writtenByMe && publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + menu.push(null); + } + + if (writtenByMe || withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + } + + if (writtenByMe) { + 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) && publicStatus && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> + ); + + const filterButton = status.get('filtered') && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} /> + ); + + let replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + /> + ); + if (showReplyCount) { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + counter={status.get('replies_count')} + obfuscateCount + /> + ); + } + + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let reblogTitle = ''; + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_reblog); + } + + return ( + <div className='status__action-bar'> + {replyButton} + {!directMessage && [ + <IconButton key='reblog-button' className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} />, + <IconButton key='favourite-button' 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 key='bookmark-button' 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} />, + filterButton, + <div key='dropdown-button' className='status__action-bar-dropdown'> + <DropdownMenuContainer + scrollKey={scrollKey} + 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..34ff97305 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -0,0 +1,386 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import Permalink from './permalink'; +import classnames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; + +const textMatchesTarget = (text, origin, host) => { + return (text === origin || text === host + || text.startsWith(origin + '/') || text.startsWith(host + '/') + || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); +} + +const isLinkMisleading = (link) => { + let linkTextParts = []; + + // Reconstruct visible text, as we do not have much control over how links + // from remote software look, and we can't rely on `innerText` because the + // `invisible` class does not set `display` to `none`. + + const walk = (node) => { + switch (node.nodeType) { + case Node.TEXT_NODE: + linkTextParts.push(node.textContent); + break; + case Node.ELEMENT_NODE: + if (node.classList.contains('invisible')) return; + const children = node.childNodes; + for (let i = 0; i < children.length; i++) { + walk(children[i]); + } + break; + } + }; + + walk(link); + + const linkText = linkTextParts.join(''); + const targetURL = new URL(link.href); + + if (targetURL.protocol === 'magnet:') { + return !linkText.startsWith('magnet:'); + } + + if (targetURL.protocol === 'xmpp:') { + return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href); + } + + // The following may not work with international domain names + if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { + return false; + } + + // The link hasn't been recognized, maybe it features an international domain name + const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC'); + const host = targetURL.host.replace(targetURL.hostname, hostname); + const origin = targetURL.origin.replace(targetURL.host, host); + const text = linkText.normalize('NFKC'); + return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); +}; + +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, + tagLinks: PropTypes.bool, + rewriteMentions: PropTypes.string, + }; + + static defaultProps = { + tagLinks: true, + rewriteMentions: 'no', + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.contentsNode; + const { tagLinks, rewriteMentions } = this.props; + + 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')); + if (rewriteMentions !== 'no') { + while (link.firstChild) link.removeChild(link.firstChild); + link.appendChild(document.createTextNode('@')); + const acctSpan = document.createElement('span'); + acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username'); + link.appendChild(acctSpan); + } + } 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.classList.add('unhandled-link'); + + try { + if (tagLinks && isLinkMisleading(link)) { + // Add a tag besides the link to display its origin + + const url = new URL(link.href); + const tag = document.createElement('span'); + tag.classList.add('link-origin-tag'); + switch (url.protocol) { + case 'xmpp:': + tag.textContent = `[${url.href}]`; + break; + case 'magnet:': + tag.textContent = '(magnet)'; + break; + default: + tag.textContent = `[${url.host}]`; + } + link.insertAdjacentText('beforeend', ' '); + link.insertAdjacentElement('beforeend', tag); + } + } catch (e) { + // The URL is invalid, remove the href just to be safe + if (tagLinks && e instanceof TypeError) link.removeAttribute('href'); + } + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } + } + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + } + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + } + + 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(/^#/, ''); + + 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 (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) { + 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 }); + } + } + + setContentsRef = (c) => { + this.contentsNode = c; + } + + render () { + const { + status, + media, + mediaIcon, + parseClick, + disabled, + tagLinks, + rewriteMentions, + } = 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 classNames = classnames('status__content', { + 'status__content--with-action': parseClick && !disabled, + 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + }); + + 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 ? ( + <Icon + fixedWidth + className='status__content__spoiler-icon' + id={mediaIcon} + 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} className='translate' /> + {' '} + <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.setContentsRef} + key={`contents-${tagLinks}`} + tabIndex={!hidden ? 0 : null} + dangerouslySetInnerHTML={content} + className='status__content__text translate' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + {media} + </div> + + </div> + ); + } else if (parseClick) { + return ( + <div + className={classNames} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + tabIndex='0' + > + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}-${rewriteMentions}`} + dangerouslySetInnerHTML={content} + className='status__content__text translate' + tabIndex='0' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + {media} + </div> + ); + } else { + return ( + <div + className='status__content' + tabIndex='0' + > + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}`} + className='status__content__text translate' + dangerouslySetInnerHTML={content} + tabIndex='0' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + {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..06296e124 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_header.js @@ -0,0 +1,90 @@ +// 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 AvatarComposite from './avatar_composite'; +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, + otherAccounts: ImmutablePropTypes.list, + }; + + // Handles clicks on account name/image + handleClick = (id, e) => { + const { parseClick } = this.props; + parseClick(e, `/accounts/${id}`); + } + + handleAccountClick = (e) => { + const { status } = this.props; + this.handleClick(status.getIn(['account', 'id']), e); + } + + // Rendering. + render () { + const { + status, + friend, + otherAccounts, + } = this.props; + + const account = status.get('account'); + + let statusAvatar; + if (otherAccounts && otherAccounts.size > 0) { + statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} onAccountClick={this.handleClick} />; + } else if (friend === undefined || friend === null) { + statusAvatar = <Avatar account={account} size={48} />; + } else { + statusAvatar = <AvatarOverlay account={account} friend={friend} />; + } + + if (!otherAccounts) { + return ( + <div className='status__info__account'> + <a + href={account.get('url')} + target='_blank' + className='status__avatar' + onClick={this.handleAccountClick} + rel='noopener noreferrer' + > + {statusAvatar} + </a> + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + rel='noopener noreferrer' + > + <DisplayName account={account} others={otherAccounts} /> + </a> + </div> + ); + } else { + // This is a DM conversation + return ( + <div className='status__info__account'> + <span className='status__avatar'> + {statusAvatar} + </span> + + <span className='status__display-name'> + <DisplayName account={account} others={otherAccounts} onAccountClick={this.handleClick} /> + </span> + </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..f4d0a7405 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_icons.js @@ -0,0 +1,121 @@ +// 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'; +import Icon from 'flavours/glitch/components/icon'; + +// Messages for use with internationalization stuff. +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' }, + previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' }, + pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' }, + poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' }, + video: { id: 'status.has_video', defaultMessage: 'Features attached videos' }, + audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' }, + localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' }, +}); + +export default @injectIntl +class StatusIcons extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + mediaIcon: PropTypes.string, + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + directMessage: 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(); + } + } + + mediaIconTitleText () { + const { intl, mediaIcon } = this.props; + + switch (mediaIcon) { + case 'link': + return intl.formatMessage(messages.previewCard); + case 'picture-o': + return intl.formatMessage(messages.pictures); + case 'tasks': + return intl.formatMessage(messages.poll); + case 'video-camera': + return intl.formatMessage(messages.video); + case 'music': + return intl.formatMessage(messages.audio); + } + } + + // Rendering. + render () { + const { + status, + mediaIcon, + collapsible, + collapsed, + directMessage, + intl, + } = this.props; + + return ( + <div className='status__info__icons'> + {status.get('in_reply_to_id', null) !== null ? ( + <Icon + className='status__reply-icon' + fixedWidth + id='comment' + aria-hidden='true' + title={intl.formatMessage(messages.inReplyTo)} + /> + ) : null} + {status.get('local_only') && + <Icon + fixedWidth + id='home' + aria-hidden='true' + title={intl.formatMessage(messages.localOnly)} + />} + {mediaIcon ? ( + <Icon + fixedWidth + className='status__media-icon' + id={mediaIcon} + aria-hidden='true' + title={this.mediaIconTitleText()} + /> + ) : null} + {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />} + {collapsible ? ( + <IconButton + className='status__collapse-button' + animate + 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..60cc23f4b --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_list.js @@ -0,0 +1,128 @@ +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 RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator'; + +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 <RegenerationIndicator />; + } + + 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} + scrollKey={this.props.scrollKey} + /> + )) + ) : null; + + if (scrollableContent && featuredStatusIds) { + scrollableContent = featuredStatusIds.map(statusId => ( + <StatusContainer + key={`f-${statusId}`} + id={statusId} + featured + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + scrollKey={this.props.scrollKey} + /> + )).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..af6acdef9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -0,0 +1,133 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; +import { me } from 'flavours/glitch/util/initial_state'; + +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 'status': + return ( + <FormattedMessage + id='notification.status' + defaultMessage='{name} just posted' + values={{ name: link }} + /> + ); + case 'poll': + if (me === account.get('id')) { + return ( + <FormattedMessage + id='notification.own_poll' + defaultMessage='Your poll has ended' + /> + ); + } else { + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); + } + } + return null; + } + + render () { + const { Message } = this; + const { type } = this.props; + + let iconId; + + switch(type) { + case 'favourite': + iconId = 'star'; + break; + case 'featured': + iconId = 'thumb-tack'; + break; + case 'poll': + iconId = 'tasks'; + break; + case 'reblog': + case 'reblogged_by': + iconId = 'retweet'; + break; + case 'status': + iconId = 'bell'; + break; + }; + + 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'}> + <Icon + className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`} + id={iconId} + /> + </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..e2e0f30b8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_visibility_icon.js @@ -0,0 +1,51 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; + +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' }, +}); + +export default @injectIntl +class VisibilityIcon extends ImmutablePureComponent { + + static propTypes = { + visibility: PropTypes.string, + intl: PropTypes.object.isRequired, + withLabel: PropTypes.bool, + }; + + render() { + const { withLabel, visibility, intl } = this.props; + + const visibilityIcon = { + public: 'globe', + unlisted: 'unlock', + private: 'lock', + direct: 'envelope', + }[visibility]; + + const label = intl.formatMessage(messages[visibility]); + + const icon = (<Icon + className='status__visibility-icon' + fixedWidth + id={visibilityIcon} + 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/timeline_hint.js b/app/javascript/flavours/glitch/components/timeline_hint.js new file mode 100644 index 000000000..fb55a62cc --- /dev/null +++ b/app/javascript/flavours/glitch/components/timeline_hint.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const TimelineHint = ({ resource, url }) => ( + <div className='timeline-hint'> + <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong> + <br /> + <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a> + </div> +); + +TimelineHint.propTypes = { + resource: PropTypes.node.isRequired, + url: PropTypes.string.isRequired, +}; + +export default TimelineHint; 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..e92e102ab --- /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: 'Block 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..1c0385b74 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js @@ -0,0 +1,32 @@ +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 => ({ + dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), + openDropdownId: state.getIn(['dropdown_menu', 'openId']), + openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), +}); + +const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ + 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, scrollKey)); + }, + onClose(id) { + dispatch(closeModal('ACTIONS')); + 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..bcdd9b54e --- /dev/null +++ b/app/javascript/flavours/glitch/containers/mastodon.js @@ -0,0 +1,65 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from 'flavours/glitch/store/configureStore'; +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()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + shouldUpdateScroll (_, { location }) { + return !(location.state && location.state.mastodonModalOpen); + } + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <ErrorBoundary> + <BrowserRouter basename='/web'> + <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> + <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..8657b8064 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -0,0 +1,121 @@ +import React, { PureComponent, Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { fromJS } from 'immutable'; +import { getLocale } from 'mastodon/locales'; +import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar'; +import MediaGallery from 'flavours/glitch/components/media_gallery'; +import Poll from 'flavours/glitch/components/poll'; +import Hashtag from 'flavours/glitch/components/hashtag'; +import ModalRoot from 'flavours/glitch/components/modal_root'; +import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; +import Video from 'flavours/glitch/features/video'; +import Card from 'flavours/glitch/features/status/components/card'; +import Audio from 'flavours/glitch/features/audio'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; + +export default class MediaContainer extends PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + components: PropTypes.object.isRequired, + }; + + state = { + media: null, + index: null, + time: null, + backgroundColor: null, + options: null, + }; + + handleOpenMedia = (media, index) => { + document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + + this.setState({ media, index }); + } + + handleOpenVideo = (options) => { + const { components } = this.props; + const { media } = JSON.parse(components[options.componetIndex].getAttribute('data-props')); + const mediaList = fromJS(media); + + document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + + this.setState({ media: mediaList, options }); + } + + handleCloseMedia = () => { + document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; + + this.setState({ + media: null, + index: null, + time: null, + backgroundColor: null, + options: null, + }); + } + + setBackgroundColor = color => { + this.setState({ backgroundColor: color }); + } + + 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, hashtag, ...props } = JSON.parse(component.getAttribute('data-props')); + + Object.assign(props, { + ...(media ? { media: fromJS(media) } : {}), + ...(card ? { card: fromJS(card) } : {}), + ...(poll ? { poll: fromJS(poll) } : {}), + ...(hashtag ? { hashtag: fromJS(hashtag) } : {}), + + ...(componentName === 'Video' ? { + componetIndex: i, + onOpenVideo: this.handleOpenVideo, + } : { + onOpenMedia: this.handleOpenMedia, + }), + }); + + return ReactDOM.createPortal( + <Component {...props} key={`media-${i}`} />, + component, + ); + })} + + <ModalRoot backgroundColor={this.state.backgroundColor} onClose={this.handleCloseMedia}> + {this.state.media && ( + <MediaModal + media={this.state.media} + index={this.state.index || 0} + currentTime={this.state.options?.startTime} + autoPlay={this.state.options?.autoPlay} + volume={this.state.options?.defaultVolume} + onClose={this.handleCloseMedia} + onChangeBackgroundColor={this.setBackgroundColor} + /> + )} + </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..345351cc6 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/poll_container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; + +import Poll from 'flavours/glitch/components/poll'; +import { fetchPoll, vote } from 'flavours/glitch/actions/polls'; + +const mapDispatchToProps = (dispatch, { pollId }) => ({ + refresh: debounce( + () => { + dispatch(fetchPoll(pollId)); + }, + 1000, + { leading: true }, + ), + + onVote (choices) { + dispatch(vote(pollId, choices)); + }, +}); + +const mapStateToProps = (state, { pollId }) => ({ + poll: state.getIn(['polls', pollId]), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(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..bc3c43d85 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -0,0 +1,261 @@ +import { connect } from 'react-redux'; +import Status from 'flavours/glitch/components/status'; +import { List as ImmutableList } from 'immutable'; +import { makeGetStatus, regexFromFilters, toServerSideType } 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 { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { initBoostModal } from 'flavours/glitch/actions/boosts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +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 { filterEditLink } from 'flavours/glitch/util/backend_links'; +import { showAlertForError } from '../actions/alerts'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Spoilers from '../components/spoilers'; +import Icon from 'flavours/glitch/components/icon'; + +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.' }, + 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?' }, + unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' }, + author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' }, + matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' }, + editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' }, +}); + +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 && status) { + 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, + usingPiP : state.get('picture_in_picture').statusId === props.id, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ + + 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, privacy) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status, privacy)); + } + }, + + onReblog (status, e) { + dispatch((_, getState) => { + let state = getState(); + if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { + dispatch(initBoostModal({ status, onReblog: this.onModalReblog, missingMediaDescription: true })); + } else if (e.shiftKey || !boostModal) { + this.onModalReblog(status); + } else { + dispatch(initBoostModal({ 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 (statusId, media, index) { + dispatch(openModal('MEDIA', { statusId, media, index })); + }, + + onOpenVideo (statusId, media, options) { + dispatch(openModal('VIDEO', { statusId, media, options })); + }, + + onBlock (status) { + const account = status.get('account'); + dispatch(initBlockModal(account)); + }, + + onUnfilter (status, onConfirm) { + dispatch((_, getState) => { + let state = getState(); + const serverSideType = toServerSideType(contextType); + const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray(); + const searchIndex = status.get('search_index'); + const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex)); + dispatch(openModal('CONFIRM', { + message: [ + <FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />, + <div className='filtered-status-info'> + <Spoilers spoilerText={intl.formatMessage(messages.author)}> + <AccountContainer id={status.getIn(['account', 'id'])} /> + </Spoilers> + <Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}> + <ul> + {matchingFilters.map(filter => ( + <li> + {filter.get('phrase')} + {!!filterEditLink && ' '} + {!!filterEditLink && ( + <a + target='_blank' + className='filtered-status-edit-link' + title={intl.formatMessage(messages.editFilter)} + href={filterEditLink(filter.get('id'))} + > + <Icon id='pencil' /> + </a> + )} + </li> + ))} + </ul> + </Spoilers> + </div> + ], + confirm: intl.formatMessage(messages.unfilterConfirm), + onConfirm: onConfirm, + })); + }); + }, + + 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'))); + } + }, + + deployPictureInPicture (status, type, mediaProps) { + dispatch((_, getState) => { + if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) { + dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); + } + }); + }, + +}); + +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..b61dc8dd7 --- /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} local={local} />; + } 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/account_note.js b/app/javascript/flavours/glitch/features/account/components/account_note.js new file mode 100644 index 000000000..6e5ed7703 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/account_note.js @@ -0,0 +1,104 @@ +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 Icon from 'flavours/glitch/components/icon'; +import Textarea from 'react-textarea-autosize'; + +const messages = defineMessages({ + placeholder: { id: 'account_note.glitch_placeholder', defaultMessage: 'No comment provided' }, +}); + +export default @injectIntl +class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + isEditing: PropTypes.bool, + isSubmitting: PropTypes.bool, + accountNote: PropTypes.string, + onEditAccountNote: PropTypes.func.isRequired, + onCancelAccountNote: PropTypes.func.isRequired, + onSaveAccountNote: PropTypes.func.isRequired, + onChangeAccountNote: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleChangeAccountNote = (e) => { + this.props.onChangeAccountNote(e.target.value); + }; + + componentWillUnmount () { + if (this.props.isEditing) { + this.props.onCancelAccountNote(); + } + } + + handleKeyDown = e => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.props.onSaveAccountNote(); + } else if (e.keyCode === 27) { + this.props.onCancelAccountNote(); + } + } + + render () { + const { account, accountNote, isEditing, isSubmitting, intl } = this.props; + + if (!account || (!accountNote && !isEditing)) { + return null; + } + + let action_buttons = null; + if (isEditing) { + action_buttons = ( + <div className='account__header__account-note__buttons'> + <button className='icon-button' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}> + <Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' /> + </button> + <div className='flex-spacer' /> + <button className='icon-button' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}> + <Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' /> + </button> + </div> + ); + } else { + action_buttons = ( + <div className='account__header__account-note__buttons'> + <button className='icon-button' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}> + <Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' /> + </button> + </div> + ); + } + + let note_container = null; + if (isEditing) { + note_container = ( + <Textarea + className='account__header__account-note__content' + disabled={isSubmitting} + placeholder={intl.formatMessage(messages.placeholder)} + value={accountNote} + onChange={this.handleChangeAccountNote} + onKeyDown={this.handleKeyDown} + autoFocus + /> + ); + } else { + note_container = (<div className='account__header__account-note__content'>{accountNote}</div>); + } + + return ( + <div className='account__header__account-note'> + <div className='account__header__account-note__header'> + <strong><FormattedMessage id='account.account_note_header' defaultMessage='Note' /></strong> + {action_buttons} + </div> + {note_container} + </div> + ); + } + +} 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..2d4cc7f49 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -0,0 +1,85 @@ +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'; + +export default @injectIntl +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; + + if (account.get('suspended')) { + return ( + <div> + <div className='account__disclaimer'> + <Icon id='info-circle' fixedWidth /> <FormattedMessage + id='account.suspended_disclaimer_full' + defaultMessage="This user has been suspended by a moderator." + /> + </div> + </div> + ); + } + + let extraInfo = ''; + + if (account.get('acct') !== account.get('username')) { + extraInfo = ( + <div className='account__disclaimer'> + <Icon id='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..4b0494fff --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -0,0 +1,341 @@ +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 { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Avatar from 'flavours/glitch/components/avatar'; +import Button from 'flavours/glitch/components/button'; +import { NavLink } from 'react-router-dom'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import AccountNoteContainer from '../containers/account_note_container'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' }, + 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: 'Block domain {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, + enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' }, + disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' }, + 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: 'Blocked 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}' }, + add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note 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, + onMention: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, + onNotifyToggle: 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, + onEditAccountNote: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + domain: PropTypes.string.isRequired, + }; + + openEditProfile = () => { + window.open(profileLink, '_blank'); + } + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + } + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + } + + render () { + const { account, intl, domain, identity_proofs } = this.props; + + if (!account) { + return null; + } + + const accountNote = account.getIn(['relationship', 'note']); + + const suspended = account.get('suspended'); + + let info = []; + let actionBtn = ''; + let bellBtn = ''; + 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 blocked' /></span>); + } + + if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { + bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />; + } + + 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={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={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']), 'button--with-bell': bellBtn !== '' })} 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 if (profileLink) { + actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; + } + + if (account.get('moved') && !account.getIn(['relationship', 'following'])) { + actionBtn = ''; + } + + if (suspended && !account.getIn(['relationship', 'following'])) { + actionBtn = ''; + } + + if (account.get('locked')) { + lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />; + } + + if (account.get('id') !== me && !suspended) { + 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 && !suspended) { + menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); + menu.push(null); + } + + if (accountNote === null || accountNote === '') { + menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote }); + } + + if (account.get('id') === me) { + if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); + if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); + 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', 'muting'])) { + 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 && accountAdminLink) { + menu.push(null); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) }); + } + + const content = { __html: account.get('note_emojified') }; + const displayNameHtml = { __html: account.get('display_name_html') }; + const fields = account.get('fields'); + const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); + + let badge; + + if (account.get('bot')) { + badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>); + } else if (account.get('group')) { + badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>); + } else { + badge = null; + } + + return ( + <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <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 noreferrer' target='_blank'> + <Avatar account={account} size={90} /> + </a> + + <div className='spacer' /> + + <div className='account__header__tabs__buttons'> + {actionBtn} + {bellBtn} + + <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> + + <AccountNoteContainer account={account} /> + + {!suspended && ( + <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') }} className='translate' /> + + <dd className='verified'> + <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><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 noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} className='translate' /></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') }} className='translate' /> + </dd> + </dl> + ))} + </div> + )} + + {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} + + <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div> + </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..17c08e375 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js @@ -0,0 +1,33 @@ +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, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + render() { + const { onClick, intl, multiColumn } = this.props; + + return ( + <ColumnHeader + icon='user-circle' + title={intl.formatMessage(messages.profile)} + onClick={onClick} + showBackButton + multiColumn={multiColumn} + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account/containers/account_note_container.js b/app/javascript/flavours/glitch/features/account/containers/account_note_container.js new file mode 100644 index 000000000..f1d007ecb --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/containers/account_note_container.js @@ -0,0 +1,34 @@ +import { connect } from 'react-redux'; +import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'flavours/glitch/actions/account_notes'; +import AccountNote from '../components/account_note'; + +const mapStateToProps = (state, { account }) => { + const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id'); + + return { + isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']), + accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']), + isEditing, + }; +}; + +const mapDispatchToProps = (dispatch, { account }) => ({ + + onEditAccountNote() { + dispatch(initEditAccountNote(account)); + }, + + onSaveAccountNote() { + dispatch(submitAccountNote()); + }, + + onCancelAccountNote() { + dispatch(cancelAccountNote()); + }, + + onChangeAccountNote(comment) { + dispatch(changeAccountNoteComment(comment)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(AccountNote); 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..7457980d2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js @@ -0,0 +1,145 @@ +import Blurhash from 'flavours/glitch/components/blurhash'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; +import { isIOS } from 'flavours/glitch/util/is_mobile'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +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, + }; + + 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, label, icon, content; + + if (!visible) { + icon = ( + <span className='account-gallery__item__icons'> + <Icon id='eye-slash' /> + </span> + ); + } else { + if (['audio', 'video'].includes(attachment.get('type'))) { + content = ( + <img + src={attachment.get('preview_url') || attachment.getIn(['account', 'avatar_static'])} + alt={attachment.get('description')} + onLoad={this.handleImageLoad} + /> + ); + + if (attachment.get('type') === 'audio') { + label = <Icon id='music' />; + } else { + label = <Icon id='play' />; + } + } 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; + + content = ( + <img + src={attachment.get('preview_url')} + alt={attachment.get('description')} + style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + ); + } else if (attachment.get('type') === 'gifv') { + content = ( + <video + className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} + role='application' + src={attachment.get('url')} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={!isIOS() && autoPlayGif} + loop + muted + /> + ); + + label = 'GIF'; + } + + thumbnail = ( + <div className='media-gallery__gifv'> + {content} + + {label && <span className='media-gallery__gifv__label'>{label}</span>} + </div> + ); + } + + return ( + <div className='account-gallery__item' style={{ width, height }}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'> + <Blurhash + hash={attachment.get('blurhash')} + className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} + dummy={!useBlurhash} + /> + + {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..2a43d1ed2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -0,0 +1,199 @@ +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']), + suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false), +}); + +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} + /> + ); + } + +} + +export default @connect(mapStateToProps) +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, + multiColumn: PropTypes.bool, + suspended: 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 => { + const { dispatch } = this.props; + const statusId = attachment.getIn(['status', 'id']); + + if (attachment.get('type') === 'video') { + dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } })); + } else if (attachment.get('type') === 'audio') { + dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } })); + } else { + const media = attachment.getIn(['status', 'media_attachments']); + const index = media.findIndex(x => x.get('id') === attachment.get('id')); + + dispatch(openModal('MEDIA', { media, index, statusId })); + } + } + + handleRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + + render () { + const { attachments, isLoading, hasMore, isAccount, multiColumn, suspended } = 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} multiColumn={multiColumn} /> + + <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}> + <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> + <HeaderContainer accountId={this.props.params.accountId} /> + + {suspended ? ( + <div className='empty-column-indicator'> + <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' /> + </div> + ) : ( + <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..a6b57d331 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js @@ -0,0 +1,140 @@ +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); + } + + handleNotifyToggle = () => { + this.props.onNotifyToggle(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); + } + + handleEditAccountNote = () => { + this.props.onEditAccountNote(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} + onNotifyToggle={this.handleNotifyToggle} + onReport={this.handleReport} + onMute={this.handleMute} + onBlockDomain={this.handleBlockDomain} + onUnblockDomain={this.handleUnblockDomain} + onEndorseToggle={this.handleEndorseToggle} + onAddToList={this.handleAddToList} + onEditAccountNote={this.handleEditAccountNote} + 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..fcaa7b494 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js @@ -0,0 +1,51 @@ +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'; +import Icon from 'flavours/glitch/components/icon'; + +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'><Icon id='suitcase' className='account__moved-note__icon' fixedWidth /></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..90e746679 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js @@ -0,0 +1,142 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Header from '../components/header'; +import { + followAccount, + unfollowAccount, + 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 { initBlockModal } from 'flavours/glitch/actions/blocks'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks'; +import { initEditAccountNote } from 'flavours/glitch/actions/account_notes'; +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' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, +}); + +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(initBlockModal(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'), { reblogs: false })); + } else { + dispatch(followAccount(account.get('id'), { reblogs: true })); + } + }, + + onEndorseToggle (account) { + if (account.getIn(['relationship', 'endorsed'])) { + dispatch(unpinAccount(account.get('id'))); + } else { + dispatch(pinAccount(account.get('id'))); + } + }, + + onNotifyToggle (account) { + if (account.getIn(['relationship', 'notifying'])) { + dispatch(followAccount(account.get('id'), { notify: false })); + } else { + dispatch(followAccount(account.get('id'), { notify: true })); + } + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + + onEditAccountNote (account) { + dispatch(initEditAccountNote(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..0d24980a9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -0,0 +1,151 @@ +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 ColumnBackButton from 'flavours/glitch/components/column_back_button'; +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'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; + +const emptyList = ImmutableList(); + +const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { + const path = withReplies ? `${accountId}:with_replies` : accountId; + + return { + remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])), + remoteUrl: state.getIn(['accounts', accountId, 'url']), + 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']), + suspended: state.getIn(['accounts', accountId, 'suspended'], false), + }; +}; + +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + +export default @connect(mapStateToProps) +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, + suspended: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, + multiColumn: 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, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props; + + if (!isAccount) { + return ( + <Column> + <ColumnBackButton multiColumn={multiColumn} /> + <MissingIndicator /> + </Column> + ); + } + + if (!statusIds && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let emptyMessage; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (remote && statusIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + + return ( + <Column ref={this.setRef} name='account'> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <StatusList + prepend={<HeaderContainer accountId={this.props.params.accountId} />} + alwaysPrepend + append={remoteMessage} + scrollKey='account_timeline' + statusIds={suspended ? emptyList : statusIds} + featuredStatusIds={featuredStatusIds} + isLoading={isLoading} + hasMore={hasMore} + onLoadMore={this.handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + timelineId='account' + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js new file mode 100644 index 000000000..ac0468f70 --- /dev/null +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -0,0 +1,527 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'flavours/glitch/features/video'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; +import { getPointerPosition, fileNameFromURL } from 'flavours/glitch/features/video'; +import { debounce } from 'lodash'; +import Visualizer from './visualizer'; + +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' }, + download: { id: 'video.download', defaultMessage: 'Download file' }, +}); + +const TICK_SIZE = 10; +const PADDING = 180; + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + poster: PropTypes.string, + duration: PropTypes.number, + width: PropTypes.number, + height: PropTypes.number, + editable: PropTypes.bool, + fullscreen: PropTypes.bool, + intl: PropTypes.object.isRequired, + cacheWidth: PropTypes.func, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, + currentTime: PropTypes.number, + autoPlay: PropTypes.bool, + volume: PropTypes.number, + muted: PropTypes.bool, + deployPictureInPicture: PropTypes.func, + }; + + state = { + width: this.props.width, + currentTime: 0, + buffer: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + dragging: false, + }; + + constructor (props) { + super(props); + this.visualizer = new Visualizer(TICK_SIZE); + } + + setPlayerRef = c => { + this.player = c; + + if (this.player) { + this._setDimensions(); + } + } + + _pack() { + return { + src: this.props.src, + volume: this.audio.volume, + muted: this.audio.muted, + currentTime: this.audio.currentTime, + poster: this.props.poster, + backgroundColor: this.props.backgroundColor, + foregroundColor: this.props.foregroundColor, + accentColor: this.props.accentColor, + }; + } + + _setDimensions () { + const width = this.player.offsetWidth; + const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); + + if (width && width != this.state.containerWidth) { + if (this.props.cacheWidth) { + this.props.cacheWidth(width); + } + + this.setState({ width, height }); + } + } + + setSeekRef = c => { + this.seek = c; + } + + setVolumeRef = c => { + this.volume = c; + } + + setAudioRef = c => { + this.audio = c; + + if (this.audio) { + this.setState({ volume: this.audio.volume, muted: this.audio.muted }); + } + } + + setCanvasRef = c => { + this.canvas = c; + + this.visualizer.setCanvas(c); + } + + componentDidMount () { + window.addEventListener('scroll', this.handleScroll); + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentDidUpdate (prevProps, prevState) { + if (this.player) { + this._setDimensions(); + } + + if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) { + this._clear(); + this._draw(); + } + } + + componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); + window.removeEventListener('resize', this.handleResize); + + if (!this.state.paused && this.audio && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } + } + + togglePlay = () => { + if (!this.audioContext) { + this._initAudioContext(); + } + + if (this.state.paused) { + this.setState({ paused: false }, () => this.audio.play()); + } else { + this.setState({ paused: true }, () => this.audio.pause()); + } + } + + handleResize = debounce(() => { + if (this.player) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + handlePlay = () => { + this.setState({ paused: false }); + + if (this.audioContext && this.audioContext.state === 'suspended') { + this.audioContext.resume(); + } + + this._renderCanvas(); + } + + handlePause = () => { + this.setState({ paused: true }); + + if (this.audioContext) { + this.audioContext.suspend(); + } + } + + handleProgress = () => { + const lastTimeRange = this.audio.buffered.length - 1; + + if (lastTimeRange > -1) { + this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) }); + } + } + + toggleMute = () => { + const muted = !this.state.muted; + + this.setState({ muted }, () => { + this.audio.muted = muted; + }); + } + + 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); + } + + 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.audio.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.audio.play(); + } + + handleMouseMove = throttle(e => { + const { x } = getPointerPosition(this.seek, e); + const currentTime = this.audio.duration * x; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.audio.currentTime = currentTime; + }); + } + }, 15); + + handleTimeUpdate = () => { + this.setState({ + currentTime: this.audio.currentTime, + duration: this.audio.duration, + }); + } + + handleMouseVolSlide = throttle(e => { + const { x } = getPointerPosition(this.volume, e); + + if(!isNaN(x)) { + this.setState({ volume: x }, () => { + this.audio.volume = x; + }); + } + }, 15); + + handleScroll = throttle(() => { + if (!this.canvas || !this.audio) { + return; + } + + const { top, height } = this.canvas.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.audio.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } + + this.setState({ paused: true }); + } + }, 150, { trailing: true }); + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + handleLoadedData = () => { + const { autoPlay, currentTime, volume, muted } = this.props; + + if (currentTime) { + this.audio.currentTime = currentTime; + } + + if (volume !== undefined) { + this.audio.volume = volume; + } + + if (muted !== undefined) { + this.audio.muted = muted; + } + + if (autoPlay) { + this.togglePlay(); + } + } + + _initAudioContext () { + const AudioContext = window.AudioContext || window.webkitAudioContext; + const context = new AudioContext(); + const source = context.createMediaElementSource(this.audio); + + this.visualizer.setAudioContext(context, source); + source.connect(context.destination); + + this.audioContext = context; + } + + handleDownload = () => { + fetch(this.props.src).then(res => res.blob()).then(blob => { + const element = document.createElement('a'); + const objectURL = URL.createObjectURL(blob); + + element.setAttribute('href', objectURL); + element.setAttribute('download', fileNameFromURL(this.props.src)); + + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + + URL.revokeObjectURL(objectURL); + }).catch(err => { + console.error(err); + }); + } + + _renderCanvas () { + requestAnimationFrame(() => { + if (!this.audio) return; + + this.handleTimeUpdate(); + this._clear(); + this._draw(); + + if (!this.state.paused) { + this._renderCanvas(); + } + }); + } + + _clear() { + this.visualizer.clear(this.state.width, this.state.height); + } + + _draw() { + this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient()); + } + + _getRadius () { + return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2); + } + + _getScaleCoefficient () { + return (this.state.height || this.props.height) / 982; + } + + _getCX() { + return Math.floor(this.state.width / 2); + } + + _getCY() { + return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient())); + } + + _getAccentColor () { + return this.props.accentColor || '#ffffff'; + } + + _getBackgroundColor () { + return this.props.backgroundColor || '#000000'; + } + + _getForegroundColor () { + return this.props.foregroundColor || '#ffffff'; + } + + seekBy (time) { + const currentTime = this.audio.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.audio.currentTime = currentTime; + }); + } + } + + handleAudioKeyDown = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + } + } + + render () { + const { src, intl, alt, editable, autoPlay } = this.props; + const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; + const progress = Math.min((currentTime / duration) * 100, 100); + + return ( + <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}> + <audio + src={src} + ref={this.setAudioRef} + preload={autoPlay ? 'auto' : 'none'} + onPlay={this.handlePlay} + onPause={this.handlePause} + onProgress={this.handleProgress} + onLoadedData={this.handleLoadedData} + crossOrigin='anonymous' + /> + + <canvas + role='button' + tabIndex='0' + className='audio-player__canvas' + width={this.state.width} + height={this.state.height} + style={{ width: '100%', position: 'absolute', top: 0, left: 0 }} + ref={this.setCanvasRef} + onClick={this.togglePlay} + onKeyDown={this.handleAudioKeyDown} + title={alt} + aria-label={alt} + /> + + <img + src={this.props.poster} + alt='' + width={(this._getRadius() - TICK_SIZE) * 2} + height={(this._getRadius() - TICK_SIZE) * 2} + style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }} + /> + + <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}%`, backgroundColor: this._getAccentColor() }} /> + + <span + className={classNames('video-player__seek__handle', { active: dragging })} + tabIndex='0' + style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }} + onKeyDown={this.handleAudioKeyDown} + /> + </div> + + <div className='video-player__controls active'> + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + + <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}> + <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> + + <span + className='video-player__volume__handle' + tabIndex='0' + style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} + /> + </div> + + <span className='video-player__time'> + <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span> + </span> + </div> + + <div className='video-player__buttons right'> + <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download> + <Icon id={'download'} fixedWidth /> + </a> + </div> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/audio/visualizer.js b/app/javascript/flavours/glitch/features/audio/visualizer.js new file mode 100644 index 000000000..77d5b5a65 --- /dev/null +++ b/app/javascript/flavours/glitch/features/audio/visualizer.js @@ -0,0 +1,136 @@ +/* +Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +const hex2rgba = (hex, alpha = 1) => { + const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +export default class Visualizer { + + constructor (tickSize) { + this.tickSize = tickSize; + } + + setCanvas(canvas) { + this.canvas = canvas; + if (canvas) { + this.context = canvas.getContext('2d'); + } + } + + setAudioContext(context, source) { + const analyser = context.createAnalyser(); + + analyser.smoothingTimeConstant = 0.6; + analyser.fftSize = 2048; + + source.connect(analyser); + + this.analyser = analyser; + } + + getTickPoints (count) { + const coords = []; + + for(let i = 0; i < count; i++) { + const rad = Math.PI * 2 * i / count; + coords.push({ x: Math.cos(rad), y: -Math.sin(rad) }); + } + + return coords; + } + + drawTick (cx, cy, mainColor, x1, y1, x2, y2) { + const dx1 = Math.ceil(cx + x1); + const dy1 = Math.ceil(cy + y1); + const dx2 = Math.ceil(cx + x2); + const dy2 = Math.ceil(cy + y2); + + const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2); + + const lastColor = hex2rgba(mainColor, 0); + + gradient.addColorStop(0, mainColor); + gradient.addColorStop(0.6, mainColor); + gradient.addColorStop(1, lastColor); + + this.context.beginPath(); + this.context.strokeStyle = gradient; + this.context.lineWidth = 2; + this.context.moveTo(dx1, dy1); + this.context.lineTo(dx2, dy2); + this.context.stroke(); + } + + getTicks (count, size, radius, scaleCoefficient) { + const ticks = this.getTickPoints(count); + const lesser = 200; + const m = []; + const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0; + const frequencyData = new Uint8Array(bufferLength); + const allScales = []; + + if (this.analyser) { + this.analyser.getByteFrequencyData(frequencyData); + } + + ticks.forEach((tick, i) => { + const coef = 1 - i / (ticks.length * 2.5); + + let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient; + + if (delta < 0) { + delta = 0; + } + + const k = radius / (radius - (size + delta)); + + const x1 = tick.x * (radius - size); + const y1 = tick.y * (radius - size); + const x2 = x1 * k; + const y2 = y1 * k; + + m.push({ x1, y1, x2, y2 }); + + if (i < 20) { + let scale = delta / (200 * scaleCoefficient); + scale = scale < 1 ? 1 : scale; + allScales.push(scale); + } + }); + + const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length; + + return m.map(({ x1, y1, x2, y2 }) => ({ + x1: x1, + y1: y1, + x2: x2 * scale, + y2: y2 * scale, + })); + } + + clear (width, height) { + this.context.clearRect(0, 0, width, height); + } + + draw (cx, cy, color, radius, coefficient) { + this.context.save(); + + const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient); + + ticks.forEach(tick => { + this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2); + }); + + this.context.restore(); + } + +} 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..4d0f58239 --- /dev/null +++ b/app/javascript/flavours/glitch/features/blocks/index.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +import PropTypes from 'prop-types'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import ScrollableList from '../../components/scrollable_list'; +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, FormattedMessage } 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']), + hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), + isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Blocks extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchBlocks()); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandBlocks()); + }, 300, { leading: true }); + + render () { + const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />; + + return ( + <Column name='blocks' bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList + scrollKey='blocks' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} />, + )} + </ScrollableList> + </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..58b9e6396 --- /dev/null +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js @@ -0,0 +1,102 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +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, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +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']), +}); + +export default @connect(mapStateToProps) +@injectIntl +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; + + const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />; + + return ( + <Column bindToDocument={!multiColumn} 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} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + /> + </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..69a4699ac --- /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' }, +}); + +export default @injectIntl +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..7341f9702 --- /dev/null +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -0,0 +1,135 @@ +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, { columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']); + const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]); + + return { + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +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 })); + } + + render () { + const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef} name='local' bindToDocument={!multiColumn} 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}`} + 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!' />} + bindToDocument={!multiColumn} + /> + </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/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js new file mode 100644 index 000000000..0ecfc9141 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +export default class CharacterCounter extends React.PureComponent { + + static propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + }; + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + return this.checkRemainingText(diff); + } + +} 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..d4804a3c2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -0,0 +1,375 @@ +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'; +import { maxChars } from 'flavours/glitch/util/initial_state'; +import CharacterCounter from './character_counter'; +import { length } from 'stringz'; + +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, + singleColumn: 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, + onPaste: PropTypes.func, + onMediaDescriptionConfirm: PropTypes.func, + }; + + static defaultProps = { + showSearch: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + getFulltextForCharacterCounting = () => { + return [ + this.props.spoiler? this.props.spoilerText: '', + countableText(this.props.text), + this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '' + ].join(''); + } + + canSubmit = () => { + const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; + const fulltext = this.getFulltextForCharacterCounting(); + + return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia)); + } + + handleSubmit = (overriddenVisibility = null) => { + const { + onSubmit, + media, + mediaDescriptionConfirmation, + onMediaDescriptionConfirm, + onChangeVisibility, + } = this.props; + + if (this.props.text !== this.textarea.value) { + // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) + // Update the state to match the current text + this.props.onChange(this.textarea.value); + } + + if (!this.canSubmit()) { + return; + } + + // Submit unless there are media with missing descriptions + if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) { + const firstWithoutDescription = media.find(item => !item.get('description')); + onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'), overriddenVisibility); + } else if (onSubmit) { + if (onChangeVisibility && overriddenVisibility) { + onChangeVisibility(overriddenVisibility); + } + 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); + } + } + + setRef = c => { + this.composeForm = c; + }; + + // 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 { + sideArm, + } = this.props; + this.handleSubmit(sideArm === 'none' ? null : sideArm); + } + + // 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']); + } + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + + if (e.keyCode == 13 && e.altKey) { + this.handleSecondarySubmit(); + } + } + + // 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; + } + } + + handleFocus = () => { + if (this.composeForm && !this.props.singleColumn) { + const { left, right } = this.composeForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.composeForm.scrollIntoView(); + } + } + } + + componentDidMount () { + this._updateFocusAndSelection({ }); + } + + componentDidUpdate (prevProps) { + this._updateFocusAndSelection(prevProps); + } + + // 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. + _updateFocusAndSelection = (prevProps) => { + const { + textarea, + spoilerText, + } = this; + const { + focusDate, + caretPosition, + isSubmitting, + preselectDate, + text, + preselectOnReply, + singleColumn, + } = 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(); + if (!singleColumn) 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, + intl, + isSubmitting, + layout, + onChangeSpoilerness, + onChangeVisibility, + onClearSuggestions, + onFetchSuggestions, + onPaste, + privacy, + sensitive, + showSearch, + sideArm, + spoiler, + spoilerText, + suggestions, + spoilersAlwaysOn, + } = this.props; + + const countText = this.getFulltextForCharacterCounting(); + + return ( + <div className='composer'> + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}> + <AutosuggestInput + placeholder={intl.formatMessage(messages.spoiler_placeholder)} + value={spoilerText} + onChange={this.handleChangeSpoiler} + onKeyDown={this.handleKeyDown} + 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' + autoFocus={false} + /> + </div> + + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={isSubmitting} + value={this.props.text} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + suggestions={this.props.suggestions} + onFocus={this.handleFocus} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} + > + <EmojiPicker onPickEmoji={handleEmoji} /> + <TextareaIcons advancedOptions={advancedOptions} /> + <div className='compose-form__modifiers'> + <UploadFormContainer /> + <PollFormContainer /> + </div> + </AutosuggestTextarea> + + <div className='composer--options-wrapper'> + <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} + /> + <div className='compose--counter-wrapper'> + <CharacterCounter text={countText} max={maxChars} /> + </div> + </div> + + <Publisher + countText={countText} + disabled={!this.canSubmit()} + 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..abf7cbba1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -0,0 +1,239 @@ +// 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'; + +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { + + static 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, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + noModal: PropTypes.bool, + container: PropTypes.func, + }; + + state = { + needsModalUpdate: false, + open: false, + openedViaKeyboard: undefined, + placement: 'bottom', + }; + + // Toggles opening and closing the dropdown. + handleToggle = ({ target, type }) => { + const { onModalOpen, noModal } = this.props; + const { open } = this.state; + + if (!noModal && isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + const modal = this.handleMakeModal(); + if (modal && onModalOpen) { + onModalOpen(modal); + } + } + } else { + const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); + } + } + + handleKeyDown = (e) => { + switch (e.key) { + case 'Escape': + this.handleClose(); + break; + } + } + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ open: false }); + } + + // 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 }); + }, + }) + ), + }; + } + + // If our modal is open and our props update, we need to also update + // the modal. + handleUpdate = () => { + const { onModalOpen } = this.props; + const { needsModalUpdate } = this.state; + + // Gets our modal object. + const modal = this.handleMakeModal(); + + // Reopens the modal with the new object. + if (needsModalUpdate && modal && onModalOpen) { + onModalOpen(modal); + } + } + + // Updates our modal as necessary. + componentDidUpdate (prevProps) { + const { items } = this.props; + const { needsModalUpdate } = this.state; + if (needsModalUpdate && items.find( + (item, i) => item.on !== prevProps.items[i].on + )) { + this.handleUpdate(); + this.setState({ needsModalUpdate: false }); + } + } + + // Rendering. + render () { + const { + active, + disabled, + title, + icon, + items, + onChange, + value, + container, + } = 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={this.handleKeyDown} + > + <IconButton + active={open || active} + className='value' + disabled={disabled} + icon={icon} + inverted + onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + <Overlay + containerPadding={20} + placement={placement} + show={open} + target={this} + container={container} + > + <DropdownMenu + items={items} + onChange={onChange} + onClose={this.handleClose} + value={value} + openedViaKeyboard={this.state.openedViaKeyboard} + /> + </Overlay> + </div> + ); + } + +} 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..bee06e64c --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -0,0 +1,239 @@ +// 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'; + +// 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.isRequired, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + value: PropTypes.string, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + style: {}, + }; + + state = { + mounted: false, + value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, + }; + + // When the document is clicked elsewhere, we close the dropdown. + handleDocumentClick = (e) => { + if (this.node && !this.node.contains(e.target)) { + this.props.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); + if (this.focusedItem) { + this.focusedItem.focus({ preventScroll: true }); + } else { + this.node.firstChild.focus({ preventScroll: true }); + } + this.setState({ mounted: true }); + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, withPassive); + } + + handleClick = (name, e) => { + const { + onChange, + onClose, + items, + } = this.props; + + const { on } = this.props.items.find(item => item.name === name); + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined')) { + onClose(); + } + onChange(name); + } + + // Handle changes differently whether the dropdown is a list of options or actions + handleChange = (name) => { + if (this.props.value) { + this.props.onChange(name); + } else { + this.setState({ value: name }); + } + } + + handleKeyDown = (name, e) => { + const { items } = this.props; + const index = items.findIndex(item => { + return (item.name === name); + }); + let element = null; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + case ' ': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1] || this.node.firstChild; + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1] || this.node.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + break; + case 'Home': + element = this.node.firstChild; + break; + case 'End': + element = this.node.lastChild; + break; + } + + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + } + + setFocusRef = c => { + this.focusedItem = c; + } + + renderItem = (item) => { + const { name, icon, meta, on, text } = item; + + const active = (name === (this.props.value || this.state.value)); + + 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.handleClick.bind(this, name)} />; + } else if (icon) { + prefix = <Icon className='icon' fixedWidth id={icon} /> + } + + return ( + <div + className={computedClass} + onClick={this.handleClick.bind(this, name)} + onKeyDown={this.handleKeyDown.bind(this, name)} + role='option' + tabIndex='0' + key={name} + data-index={name} + ref={active ? this.setFocusRef : null} + > + {prefix} + + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + </div> + ); + } + + // Rendering. + render () { + const { mounted } = this.state; + const { + items, + onChange, + onClose, + style, + } = 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} + role='listbox' + style={{ + ...style, + opacity: opacity, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, + }} + > + {!!items && items.map(item => this.renderItem(item))} + </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..5b456b717 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/header.js @@ -0,0 +1,134 @@ +// 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, + onLogout: PropTypes.func.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + } + + 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 id='asterisk' /></Link> + {renderForColumn('HOME', ( + <Link + aria-label={intl.formatMessage(messages.home_timeline)} + title={intl.formatMessage(messages.home_timeline)} + to='/timelines/home' + ><Icon id='home' /></Link> + ))} + {renderForColumn('NOTIFICATIONS', ( + <Link + aria-label={intl.formatMessage(messages.notifications)} + title={intl.formatMessage(messages.notifications)} + to='/notifications' + > + <span className='icon-badge-wrapper'> + <Icon id='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 id='users' /></Link> + ))} + {renderForColumn('PUBLIC', ( + <Link + aria-label={intl.formatMessage(messages.public)} + title={intl.formatMessage(messages.public)} + to='/timelines/public' + ><Icon id='globe' /></Link> + ))} + <a + aria-label={intl.formatMessage(messages.settings)} + onClick={onSettingsClick} + href='#' + title={intl.formatMessage(messages.settings)} + ><Icon id='cogs' /></a> + <a + aria-label={intl.formatMessage(messages.logout)} + onClick={this.handleLogoutClick} + href={ signOutLink } + title={intl.formatMessage(messages.logout)} + ><Icon id='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..f6bfbdd1e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js @@ -0,0 +1,38 @@ +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={48} /> + </Permalink> + + <div className='navigation-bar__profile'> + <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> + </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..f9212bbae --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -0,0 +1,295 @@ +// 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 './text_icon_button'; +import Dropdown from './dropdown'; +import PrivacyDropdown from './privacy_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', + }, + content_type: { + defaultMessage: 'Content type', + id: 'content-type.change', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + html: { + defaultMessage: 'HTML', + id: 'compose.content-type.html', + }, + 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', + }, + markdown: { + defaultMessage: 'Markdown', + id: 'compose.content-type.markdown', + }, + plain: { + defaultMessage: 'Plain text', + id: 'compose.content-type.plain', + }, + 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', + }, + 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, + onChangeContentType: PropTypes.func, + onTogglePoll: PropTypes.func, + onDoodleOpen: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + onToggleSpoiler: PropTypes.func, + onUpload: PropTypes.func, + privacy: PropTypes.string, + contentType: PropTypes.string, + resetFileKey: PropTypes.number, + spoiler: PropTypes.bool, + showContentTypeChoice: 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, + contentType, + disabled, + allowMedia, + hasMedia, + allowPoll, + hasPoll, + intl, + onChangeAdvancedOption, + onChangeContentType, + onChangeVisibility, + onTogglePoll, + onModalClose, + onModalOpen, + onToggleSpoiler, + privacy, + resetFileKey, + spoiler, + showContentTypeChoice, + } = this.props; + + const contentTypeItems = { + plain: { + icon: 'file-text', + name: 'text/plain', + text: <FormattedMessage {...messages.plain} />, + }, + html: { + icon: 'code', + name: 'text/html', + text: <FormattedMessage {...messages.html} />, + }, + markdown: { + icon: 'arrow-circle-down', + name: 'text/markdown', + text: <FormattedMessage {...messages.markdown} />, + }, + }; + + // 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 /> + <PrivacyDropdown + disabled={disabled} + onChange={onChangeVisibility} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + value={privacy} + /> + {showContentTypeChoice && ( + <Dropdown + disabled={disabled} + icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon} + items={[ + contentTypeItems.plain, + contentTypeItems.html, + contentTypeItems.markdown, + ]} + onChange={onChangeContentType} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.content_type)} + value={contentType} + /> + )} + {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..e4b5104f3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js @@ -0,0 +1,165 @@ +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, + autoFocus: 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, autoFocus, intl } = this.props; + + return ( + <li> + <label className='poll__option 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={[':']} + autoFocus={autoFocus} + /> + </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; + } + + const autoFocusIndex = options.indexOf(''); + + 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} autoFocus={i === autoFocusIndex} {...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 id='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> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <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/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js new file mode 100644 index 000000000..39f7c7bd1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import Dropdown from './dropdown'; + +const messages = defineMessages({ + change_privacy: { + defaultMessage: 'Adjust status privacy', + id: 'privacy.change', + }, + direct_long: { + defaultMessage: 'Visible for mentioned users only', + id: 'privacy.direct.long', + }, + direct_short: { + defaultMessage: 'Direct', + id: 'privacy.direct.short', + }, + private_long: { + defaultMessage: 'Visible for followers only', + id: 'privacy.private.long', + }, + private_short: { + defaultMessage: 'Followers-only', + id: 'privacy.private.short', + }, + public_long: { + defaultMessage: 'Visible for all, shown in public timelines', + id: 'privacy.public.long', + }, + public_short: { + defaultMessage: 'Public', + id: 'privacy.public.short', + }, + unlisted_long: { + defaultMessage: 'Visible for all, but not in public timelines', + id: 'privacy.unlisted.long', + }, + unlisted_short: { + defaultMessage: 'Unlisted', + id: 'privacy.unlisted.short', + }, +}); + +export default @injectIntl +class PrivacyDropdown extends React.PureComponent { + + static propTypes = { + isUserTouching: PropTypes.func, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + noDirect: PropTypes.bool, + noModal: PropTypes.bool, + container: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + render () { + const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, noModal, container, intl } = 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} />, + }, + }; + + const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private]; + + if (!noDirect) { + items.push(privacyItems.direct); + } + + return ( + <Dropdown + disabled={disabled} + icon={(privacyItems[value] || {}).icon} + items={items} + onChange={onChange} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.change_privacy)} + container={container} + noModal={noModal} + value={value} + /> + ); + } + +} 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..1531dcaa9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -0,0 +1,118 @@ +// 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']), + }; + + handleSubmit = () => { + this.props.onSubmit(); + }; + + render () { + const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props; + + const diff = maxChars - length(countText || ''); + const computedClass = classNames('composer--publisher', { + disabled: disabled, + over: diff < 0, + }); + + return ( + <div className={computedClass}> + {sideArm && sideArm !== 'none' ? ( + <Button + className='side_arm' + disabled={disabled} + onClick={onSecondarySubmit} + style={{ padding: null }} + text={ + <span> + <Icon + id={{ + 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 + id={{ + 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={this.handleSubmit} + disabled={disabled} + /> + </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..37ae9cab9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js @@ -0,0 +1,82 @@ +// 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'; + +// Messages. +const messages = defineMessages({ + cancel: { + defaultMessage: 'Cancel', + id: 'reply_indicator.cancel', + }, +}); + + +export default @injectIntl +class ReplyIndicator extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + 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 translate' + dangerouslySetInnerHTML={{ __html: content || '' }} + /> + {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..12d839637 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search.js @@ -0,0 +1,177 @@ +// 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, zIndex: 2 }}> + <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='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 contextTypes = { + router: PropTypes.object.isRequired, + }; + + 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, + openInRoute: PropTypes.bool, + intl: PropTypes.object.isRequired, + singleColumn: PropTypes.bool, + }; + + state = { + expanded: false, + }; + + setRef = c => { + this.searchForm = c; + } + + 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 = () => { + this.setState({ expanded: true }); + this.props.onShow(); + + if (this.searchForm && !this.props.singleColumn) { + const { left, right } = this.searchForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.searchForm.scrollIntoView(); + } + } + } + + handleKeyUp = (e) => { + const { onSubmit } = this.props; + switch (e.key) { + case 'Enter': + onSubmit(); + + if (this.props.openInRoute) { + this.context.router.history.push('/search'); + } + break; + case 'Escape': + focusRoot(); + } + } + + render () { + const { intl, value, submitted } = this.props; + const { expanded } = this.state; + const hasValue = value.length > 0 || submitted; + + return ( + <div className='search'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> + <input + ref={this.setRef} + className='search__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='search__icon' + onClick={this.handleClear} + role='button' + tabIndex='0' + > + <Icon id='search' className={hasValue ? '' : 'active'} /> + <Icon id='times-circle' className={hasValue ? 'active' : ''} /> + </div> + + <Overlay show={expanded && !hasValue} 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..a0f86a06a --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -0,0 +1,142 @@ +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'; +import { searchEnabled } from 'flavours/glitch/util/initial_state'; +import LoadMore from 'flavours/glitch/components/load_more'; + +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, + expandSearch: PropTypes.func.isRequired, + dismissSuggestion: PropTypes.func.isRequired, + searchTerm: PropTypes.string, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + if (this.props.searchTerm === '') { + this.props.fetchSuggestions(); + } + } + + componentDidUpdate () { + if (this.props.searchTerm === '') { + this.props.fetchSuggestions(); + } + } + + handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); + + handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); + + handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); + + render () { + const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; + + if (searchTerm === '' && !suggestions.isEmpty()) { + return ( + <div className='drawer--results'> + <div className='trends'> + <div className='trends__header'> + <Icon fixedWidth id='user-plus' /> + <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> + </div> + + {suggestions && suggestions.map(suggestion => ( + <AccountContainer + key={suggestion.get('account')} + id={suggestion.get('account')} + actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null} + actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null} + onActionClick={dismissSuggestion} + /> + ))} + </div> + </div> + ); + } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { + statuses = ( + <section> + <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + + <div className='search-results__info'> + <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' /> + </div> + </section> + ); + } + + 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 id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5> + + {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)} + + {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} + </section> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <section> + <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + + {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)} + + {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} + </section> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <section> + <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> + + {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + + {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} + </section> + ); + } + + // The result. + return ( + <div className='drawer--results'> + <header className='search-results__header'> + <Icon id='search' fixedWidth /> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </header> + + <div className='search-results__contents'> + {accounts} + {statuses} + {hashtags} + </div> + </div> + ); + }; +} diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..7f2005060 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const iconStyle = { + height: null, + lineHeight: '27px', + width: `${18 * 1.28571429}px`, +}; + +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} + style={iconStyle} + > + {label} + </button> + ); + } + +} 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..b875fb15e --- /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 + fixedWidth + id={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..425b0fe5e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -0,0 +1,57 @@ +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 { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; + +export default class Upload extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + onUndo: PropTypes.func.isRequired, + onOpenFocalPoint: PropTypes.func.isRequired, + }; + + handleUndoClick = e => { + e.stopPropagation(); + this.props.onUndo(this.props.media.get('id')); + } + + handleFocalPointClick = e => { + e.stopPropagation(); + this.props.onOpenFocalPoint(this.props.media.get('id')); + } + + render () { + const { intl, media } = this.props; + 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='composer--upload_form--item' tabIndex='0' 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: true })}> + <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> + <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> + </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..43039c674 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -0,0 +1,34 @@ +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'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadForm extends ImmutablePureComponent { + static propTypes = { + mediaIds: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { mediaIds } = this.props; + + return ( + <div className='composer--upload_form'> + <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} /> + + {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..493bb9ca5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -0,0 +1,43 @@ +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 Icon from 'flavours/glitch/components/icon'; + +export default class UploadProgress extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + progress: PropTypes.number, + icon: PropTypes.string.isRequired, + message: PropTypes.node.isRequired, + }; + + render () { + const { active, progress, icon, message } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='composer--upload_form--progress'> + <Icon id={icon} /> + + <div className='message'> + {message} + + <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..fcd2caf1b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -0,0 +1,135 @@ +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import ComposeForm from '../components/compose_form'; +import { + changeCompose, + changeComposeSpoilerText, + changeComposeSpoilerness, + changeComposeVisibility, + clearComposeSuggestions, + fetchComposeSuggestions, + insertEmojiCompose, + selectComposeSuggestion, + submitCompose, + 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' }, + missingDescriptionEdit: { id: 'confirmations.missing_media_description.edit', + defaultMessage: 'Edit media' }, +}); + +// 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)); + }, + + onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.missingDescriptionMessage), + confirm: intl.formatMessage(messages.missingDescriptionConfirm), + onConfirm: () => { + if (overriddenVisibility) { + dispatch(changeComposeVisibility(overriddenVisibility)); + }; + dispatch(submitCompose(routerHistory)); + }, + secondary: intl.formatMessage(messages.missingDescriptionEdit), + onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), + })); + }, + +}); + +export default injectIntl(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..b4dcb4d56 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js @@ -0,0 +1,35 @@ +import { openModal } from 'flavours/glitch/actions/modal'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import Header from '../components/header'; +import { logOut } from 'flavours/glitch/util/log_out'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +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', {})); + }, + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default injectIntl(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..c792aa582 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js @@ -0,0 +1,61 @@ +import { connect } from 'react-redux'; +import Options from '../components/options'; +import { + changeComposeAdvancedOption, + changeComposeContentType, + 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']); + const pending_media = state.getIn(['compose', 'pending_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 + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4), + hasMedia: media && !!media.size, + allowPoll: !(media && !!media.size), + showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']), + contentType: state.getIn(['compose', 'content_type']), + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + + onChangeAdvancedOption(option, value) { + dispatch(changeComposeAdvancedOption(option, value)); + }, + + onChangeContentType(value) { + dispatch(changeComposeContentType(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..5c2c1be23 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; +import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; +import { expandSearch } from 'flavours/glitch/actions/search'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']), + suggestions: state.getIn(['suggestions', 'items']), + searchTerm: state.getIn(['search', 'searchTerm']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchSuggestions: () => dispatch(fetchSuggestions()), + expandSearch: type => dispatch(expandSearch(type)), + 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..9c23d3f47 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,75 @@ +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'; + +const messages = defineMessages({ + marked: { + id: 'compose_form.sensitive.marked', + defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}', + }, + unmarked: { + id: 'compose_form.sensitive.unmarked', + defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {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']), + mediaCount: state.getIn(['compose', 'media_attachments']).size, + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + mediaCount: PropTypes.number, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { active, disabled, mediaCount, onClick, intl } = this.props; + + return ( + <div className='compose-form__sensitive-button'> + <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}> + <input + name='mark-sensitive' + type='checkbox' + checked={active} + onChange={onClick} + disabled={disabled} + /> + + <span className={classNames('checkbox', { active })} /> + + <FormattedMessage + id='compose_form.sensitive.hide' + defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}' + values={{ count: mediaCount }} + /> + </label> + </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..f687fae99 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import Upload from '../components/upload'; +import { undoUploadCompose } 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)); + }, + + 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..ab9d2123a --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -0,0 +1,68 @@ +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'; +import { profileLink, termsLink } from 'flavours/glitch/util/backend_links'; + +const buildHashtagRE = () => { + try { + const HASHTAG_SEPARATORS = "_\\u00b7\\u200c"; + const ALPHA = '\\p{L}\\p{M}'; + const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}'; + return new RegExp( + '(?:^|[^\\/\\)\\w])#((' + + '[' + WORD + '_]' + + '[' + WORD + HASHTAG_SEPARATORS + ']*' + + '[' + ALPHA + HASHTAG_SEPARATORS + ']' + + '[' + WORD + HASHTAG_SEPARATORS +']*' + + '[' + WORD + '_]' + + ')|(' + + '[' + WORD + '_]*' + + '[' + ALPHA + ']' + + '[' + WORD + '_]*' + + '))', 'iu' + ); + } catch { + return /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; + } +}; + +const APPROX_HASHTAG_RE = buildHashtagRE(); + +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={profileLink}><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.' /> {!!termsLink && <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..cb6c54a48 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/index.js @@ -0,0 +1,111 @@ +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 { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; +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()); + }, + + onMount () { + dispatch(mountCompose()); + }, + + onUnmount () { + dispatch(unmountCompose()); + }, +}); + +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, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + const { isSearchPage } = this.props; + + if (!isSearchPage) { + this.props.onMount(); + } + } + + componentWillUnmount () { + const { isSearchPage } = this.props; + + if (!isSearchPage) { + this.props.onUnmount(); + } + } + + 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 /> + + <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..ce14e2a9d --- /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' }, +}); + +export default @injectIntl +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/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js new file mode 100644 index 000000000..98b48cd90 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js @@ -0,0 +1,229 @@ +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 AttachmentList from 'flavours/glitch/components/attachment_list'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import AvatarComposite from 'flavours/glitch/components/avatar_composite'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import { HotKeys } from 'react-hotkeys'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import classNames from 'classnames'; + +const messages = defineMessages({ + more: { id: 'status.more', defaultMessage: 'More' }, + open: { id: 'conversation.open', defaultMessage: 'View conversation' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' }, + delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, +}); + +export default @injectIntl +class Conversation extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + conversationId: PropTypes.string.isRequired, + accounts: ImmutablePropTypes.list.isRequired, + lastStatus: ImmutablePropTypes.map, + unread:PropTypes.bool.isRequired, + scrollKey: PropTypes.string, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + markRead: PropTypes.func.isRequired, + delete: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + isExpanded: undefined, + }; + + parseClick = (e, destination) => { + const { router } = this.context; + const { lastStatus, unread, markRead } = this.props; + if (!router) return; + + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (destination === undefined) { + if (unread) { + markRead(); + } + destination = `/statuses/${lastStatus.get('id')}`; + } + let state = {...router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + router.history.push(destination, state); + e.preventDefault(); + } + } + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + } + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + } + + handleClick = () => { + if (!this.context.router) { + return; + } + + const { lastStatus, unread, markRead } = this.props; + + if (unread) { + markRead(); + } + + this.context.router.history.push(`/statuses/${lastStatus.get('id')}`); + } + + handleMarkAsRead = () => { + this.props.markRead(); + } + + handleReply = () => { + this.props.reply(this.props.lastStatus, this.context.router.history); + } + + handleDelete = () => { + this.props.delete(); + } + + handleHotkeyMoveUp = () => { + this.props.onMoveUp(this.props.conversationId); + } + + handleHotkeyMoveDown = () => { + this.props.onMoveDown(this.props.conversationId); + } + + handleConversationMute = () => { + this.props.onMute(this.props.lastStatus); + } + + handleShowMore = () => { + if (this.props.lastStatus.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + setExpansion = value => { + this.setState({ isExpanded: value }); + } + + render () { + const { accounts, lastStatus, unread, scrollKey, intl } = this.props; + const { isExpanded } = this.state; + + if (lastStatus === null) { + return null; + } + + const menu = [ + { text: intl.formatMessage(messages.open), action: this.handleClick }, + null, + ]; + + menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute }); + + if (unread) { + menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead }); + menu.push(null); + } + + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); + + const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]); + + const handlers = { + reply: this.handleReply, + open: this.handleClick, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleShowMore, + }; + + let media = null; + if (lastStatus.get('media_attachments').size > 0) { + media = <AttachmentList compact media={lastStatus.get('media_attachments')} />; + } + + return ( + <HotKeys handlers={handlers}> + <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex='0'> + <div className='conversation__avatar' onClick={this.handleClick} role='presentation'> + <AvatarComposite accounts={accounts} size={48} /> + </div> + + <div className='conversation__content'> + <div className='conversation__content__info'> + <div className='conversation__content__relative-time'> + {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> + </div> + + <div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> + </div> + </div> + + <StatusContent + status={lastStatus} + parseClick={this.parseClick} + expanded={isExpanded} + onExpandedToggle={this.handleShowMore} + collapsable + media={media} + /> + + <div className='status__action-bar'> + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} /> + + <div className='status__action-bar-dropdown'> + <DropdownMenuContainer + scrollKey={scrollKey} + status={lastStatus} + items={menu} + icon='ellipsis-h' + size={18} + direction='right' + title={intl.formatMessage(messages.more)} + /> + </div> + </div> + </div> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js new file mode 100644 index 000000000..c2aff1b57 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ConversationContainer from '../containers/conversation_container'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import { debounce } from 'lodash'; + +export default class ConversationsList extends ImmutablePureComponent { + + static propTypes = { + conversations: ImmutablePropTypes.list.isRequired, + scrollKey: PropTypes.string.isRequired, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + onLoadMore: PropTypes.func, + }; + + getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id) + + handleMoveUp = id => { + const elementIndex = this.getCurrentIndex(id) - 1; + this._selectChild(elementIndex, true); + } + + handleMoveDown = id => { + const elementIndex = this.getCurrentIndex(id) + 1; + this._selectChild(elementIndex, false); + } + + _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; + } + + handleLoadOlder = debounce(() => { + const last = this.props.conversations.last(); + + if (last && last.get('last_status')) { + this.props.onLoadMore(last.get('last_status')); + } + }, 300, { leading: true }) + + render () { + const { conversations, onLoadMore, ...other } = this.props; + + return ( + <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> + {conversations.map(item => ( + <ConversationContainer + key={item.get('id')} + conversationId={item.get('id')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + scrollKey={this.props.scrollKey} + /> + ))} + </ScrollableList> + ); + } + +} 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/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js new file mode 100644 index 000000000..b15ce9f0f --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js @@ -0,0 +1,74 @@ +import { connect } from 'react-redux'; +import Conversation from '../components/conversation'; +import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/actions/statuses'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + 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?' }, +}); + +const mapStateToProps = () => { + const getStatus = makeGetStatus(); + + return (state, { conversationId }) => { + const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); + const lastStatusId = conversation.get('last_status', null); + + return { + accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), + unread: conversation.get('unread'), + lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }), + }; + }; +}; + +const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({ + + markRead () { + dispatch(markConversationRead(conversationId)); + }, + + reply (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)); + } + }); + }, + + delete () { + dispatch(deleteConversation(conversationId)); + }, + + onMute (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + + onToggleHidden (status) { + if (status.get('hidden')) { + dispatch(revealStatus(status.get('id'))); + } else { + dispatch(hideStatus(status.get('id'))); + } + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation)); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js new file mode 100644 index 000000000..e10558f3a --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import ConversationsList from '../components/conversations_list'; +import { expandConversations } from 'flavours/glitch/actions/conversations'; + +const mapStateToProps = state => ({ + conversations: state.getIn(['conversations', 'items']), + isLoading: state.getIn(['conversations', 'isLoading'], true), + hasMore: state.getIn(['conversations', 'hasMore'], false), +}); + +const mapDispatchToProps = dispatch => ({ + onLoadMore: maxId => dispatch(expandConversations({ maxId })), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList); 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..7741c6922 --- /dev/null +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js @@ -0,0 +1,178 @@ +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 { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations'; +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'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import ConversationsListContainer from './containers/conversations_list_container'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Direct messages' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, + conversationsMode: state.getIn(['settings', 'direct', 'conversations']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class DirectTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + conversationsMode: 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, conversationsMode } = this.props; + + dispatch(mountConversations()); + + if (conversationsMode) { + dispatch(expandConversations()); + } else { + dispatch(expandDirectTimeline()); + } + + this.disconnect = dispatch(connectDirectStream()); + } + + componentDidUpdate(prevProps) { + const { dispatch, conversationsMode } = this.props; + + if (prevProps.conversationsMode && !conversationsMode) { + dispatch(expandDirectTimeline()); + } else if (!prevProps.conversationsMode && conversationsMode) { + dispatch(expandConversations()); + } + } + + componentWillUnmount () { + this.props.dispatch(unmountConversations()); + + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMoreTimeline = maxId => { + this.props.dispatch(expandDirectTimeline({ maxId })); + } + + handleLoadMoreConversations = maxId => { + this.props.dispatch(expandConversations({ maxId })); + } + + handleTimelineClick = () => { + this.props.dispatch(changeSetting(['direct', 'conversations'], false)); + } + + handleConversationsClick = () => { + this.props.dispatch(changeSetting(['direct', 'conversations'], true)); + } + + render () { + const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props; + const pinned = !!columnId; + + let contents; + if (conversationsMode) { + contents = ( + <ConversationsListContainer + 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." />} + /> + ); + } else { + contents = ( + <StatusListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + onLoadMore={this.handleLoadMoreTimeline} + 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." />} + /> + ); + } + + return ( + <Column bindToDocument={!multiColumn} 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> + + <div className='notification__filter-bar'> + <button + className={conversationsMode ? 'active' : ''} + onClick={this.handleConversationsClick} + > + <FormattedMessage + id='direct.conversations_mode' + defaultMessage='Conversations' + /> + </button> + <button + className={conversationsMode ? '' : 'active'} + onClick={this.handleTimelineClick} + > + <FormattedMessage + id='direct.timeline_mode' + defaultMessage='Timeline' + /> + </button> + </div> + + {contents} + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js new file mode 100644 index 000000000..9fe84c10b --- /dev/null +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js @@ -0,0 +1,271 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/util/initial_state'; +import ShortNumber from 'flavours/glitch/components/short_number'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + unmuteAccount, +} from 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; + +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}' }, + unfollowConfirm: { + id: 'confirmations.unfollow.confirm', + defaultMessage: 'Unfollow', + }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, 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)); + } + }, +}); + +export default +@injectIntl +@connect(makeMapStateToProps, mapDispatchToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + }; + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + } + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + } + + handleFollow = () => { + this.props.onFollow(this.props.account); + }; + + handleBlock = () => { + this.props.onBlock(this.props.account); + }; + + handleMute = () => { + this.props.onMute(this.props.account); + }; + + render() { + const { account, intl } = this.props; + + let buttons; + + if ( + account.get('id') !== me && + 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) { + buttons = ( + <IconButton + active + icon='volume-up' + title={intl.formatMessage(messages.unmute, { + name: account.get('username'), + })} + onClick={this.handleMute} + /> + ); + } 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 ( + <div className='directory__card'> + <div className='directory__card__img'> + <img + src={ + autoPlayGif ? account.get('header') : account.get('header_static') + } + alt='' + /> + </div> + + <div className='directory__card__bar'> + <Permalink + className='directory__card__bar__name' + href={account.get('url')} + to={`/accounts/${account.get('id')}`} + > + <Avatar account={account} size={48} /> + <DisplayName account={account} /> + </Permalink> + + <div className='directory__card__bar__relationship account__relationship'> + {buttons} + </div> + </div> + + <div className='directory__card__extra' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <div + className='account__header__content translate' + dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} + /> + </div> + + <div className='directory__card__extra'> + <div className='accounts-table__count'> + <ShortNumber value={account.get('statuses_count')} /> + <small> + <FormattedMessage id='account.posts' defaultMessage='Toots' /> + </small> + </div> + <div className='accounts-table__count'> + {account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '} + <small> + <FormattedMessage + id='account.followers' + defaultMessage='Followers' + /> + </small> + </div> + <div className='accounts-table__count'> + {account.get('last_status_at') === null ? ( + <FormattedMessage + id='account.never_active' + defaultMessage='Never' + /> + ) : ( + <RelativeTimestamp timestamp={account.get('last_status_at')} /> + )}{' '} + <small> + <FormattedMessage + id='account.last_status' + defaultMessage='Last active' + /> + </small> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js new file mode 100644 index 000000000..858a8fa55 --- /dev/null +++ b/app/javascript/flavours/glitch/features/directory/index.js @@ -0,0 +1,171 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +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 { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns'; +import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory'; +import { List as ImmutableList } from 'immutable'; +import AccountCard from './components/account_card'; +import RadioButton from 'flavours/glitch/components/radio_button'; +import classNames from 'classnames'; +import LoadMore from 'flavours/glitch/components/load_more'; +import { ScrollContainer } from 'react-router-scroll-4'; + +const messages = defineMessages({ + title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, + recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' }, + newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' }, + local: { id: 'directory.local', defaultMessage: 'From {domain} only' }, + federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), + isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + params: PropTypes.shape({ + order: PropTypes.string, + local: PropTypes.bool, + }), + }; + + state = { + order: null, + local: null, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state))); + } + } + + getParams = (props, state) => ({ + order: state.order === null ? (props.params.order || 'active') : state.order, + local: state.local === null ? (props.params.local || false) : state.local, + }); + + handleMove = dir => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchDirectory(this.getParams(this.props, this.state))); + } + + componentDidUpdate (prevProps, prevState) { + const { dispatch } = this.props; + const paramsOld = this.getParams(prevProps, prevState); + const paramsNew = this.getParams(this.props, this.state); + + if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) { + dispatch(fetchDirectory(paramsNew)); + } + } + + setRef = c => { + this.column = c; + } + + handleChangeOrder = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['order'], e.target.value)); + } else { + this.setState({ order: e.target.value }); + } + } + + handleChangeLocal = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1')); + } else { + this.setState({ local: e.target.value === '1' }); + } + } + + handleLoadMore = () => { + const { dispatch } = this.props; + dispatch(expandDirectory(this.getParams(this.props, this.state))); + } + + render () { + const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props; + const { order, local } = this.getParams(this.props, this.state); + const pinned = !!columnId; + + const scrollableArea = ( + <div className='scrollable' style={{ background: 'transparent' }}> + <div className='filter-form'> + <div className='filter-form__column' role='group'> + <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} /> + <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} /> + </div> + + <div className='filter-form__column' role='group'> + <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} /> + <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} /> + </div> + </div> + + <div className={classNames('directory__list', { loading: isLoading })}> + {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)} + </div> + + <LoadMore onClick={this.handleLoadMore} visible={!isLoading} /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='address-book-o' + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea} + </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..acce87d5a --- /dev/null +++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +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, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, +}); + +const mapStateToProps = state => ({ + domains: state.getIn(['domain_lists', 'blocks', 'items']), + hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Blocks extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + hasMore: PropTypes.bool, + domains: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchDomainBlocks()); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandDomainBlocks()); + }, 300, { leading: true }); + + render () { + const { intl, domains, hasMore, multiColumn } = this.props; + + if (!domains) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />; + + return ( + <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList + scrollKey='domain_blocks' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {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..5fd904593 --- /dev/null +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -0,0 +1,466 @@ +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 { supportsPassiveEvents } from 'detect-passive-events'; +import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji'; +import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; +import { assetHost } from 'flavours/glitch/util/config'; + +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); + } + }, +}); + +let EmojiPicker, Emoji; // load asynchronously + +const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +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} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> + <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></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} native={useSystemEmojiFont} /> + <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, event) => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + if (!(event.ctrlKey || event.metaKey)) { + 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; + + const categoriesSort = [ + 'recent', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', + ]; + + categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort()); + + 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 + native={useSystemEmojiFont} + /> + + <ModifierPicker + active={modifierOpen} + modifier={skinTone} + onOpen={this.handleModifierOpen} + onClose={this.handleModifierClose} + onChange={this.handleModifierChange} + /> + </div> + ); + } + +} + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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, + button: PropTypes.node, + }; + + 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, button } = 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}> + {button || <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..c6470ba74 --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js @@ -0,0 +1,102 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +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, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +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']), +}); + +export default @connect(mapStateToProps) +@injectIntl +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; + + const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />; + + return ( + <Column bindToDocument={!multiColumn} 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} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + /> + </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..bd6f782ce --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourites/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 LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { fetchFavourites } from 'flavours/glitch/actions/interactions'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import Icon from 'flavours/glitch/components/icon'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.favourited_by', defaultMessage: 'Favourited by' }, + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, +}); + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Favourites extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + if (!this.props.accountIds) { + 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)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleRefresh = () => { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + } + + render () { + const { intl, accountIds, multiColumn } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this toot yet. When someone does, they will show up here.' />; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='star' + title={intl.formatMessage(messages.heading)} + onClick={this.handleHeaderClick} + showBackButton + multiColumn={multiColumn} + extraButton={( + <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button> + )} + /> + <ScrollableList + scrollKey='favourites' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js new file mode 100644 index 000000000..046d03a9b --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { injectIntl, defineMessages } from 'react-intl'; +import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + }); + + return mapStateToProps; +}; + +const getFirstSentence = str => { + const arr = str.split(/(([\.\?!]+\s)|[.。?!\n•])/); + + return arr[0]; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + handleFollow = () => { + const { account, dispatch } = this.props; + + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + dispatch(unfollowAccount(account.get('id'))); + } else { + dispatch(followAccount(account.get('id'))); + } + } + + render () { + const { account, intl } = this.props; + + let button; + + if (account.getIn(['relationship', 'following'])) { + button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />; + } + + return ( + <div className='account follow-recommendations-account'> + <div className='account__wrapper'> + <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + + <DisplayName account={account} /> + + <div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div> + </Permalink> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/index.js b/app/javascript/flavours/glitch/features/follow_recommendations/index.js new file mode 100644 index 000000000..fee4d8757 --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_recommendations/index.js @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { fetchSuggestions } from 'flavours/glitch/actions/suggestions'; +import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings'; +import { requestBrowserPermission } from 'flavours/glitch/actions/notifications'; +import { markAsPartial } from 'flavours/glitch/actions/timelines'; +import Column from 'flavours/glitch/features/ui/components/column'; +import Account from './components/account'; +import Logo from 'flavours/glitch/components/logo'; +import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg'; +import Button from 'flavours/glitch/components/button'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), +}); + +export default @connect(mapStateToProps) +class FollowRecommendations extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + }; + + componentDidMount () { + const { dispatch, suggestions } = this.props; + + // Don't re-fetch if we're e.g. navigating backwards to this page, + // since we don't want followed accounts to disappear from the list + + if (suggestions.size === 0) { + dispatch(fetchSuggestions(true)); + } + } + + componentWillUnmount () { + const { dispatch } = this.props; + + // Force the home timeline to be reloaded when the user navigates + // to it; if the user is new, it would've been empty before + + dispatch(markAsPartial('home')); + } + + handleDone = () => { + const { dispatch } = this.props; + const { router } = this.context; + + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'status'], true)); + dispatch(saveSettings()); + } + })); + + router.history.push('/timelines/home'); + } + + render () { + const { suggestions, isLoading } = this.props; + + return ( + <Column> + <div className='scrollable follow-recommendations-container'> + <div className='column-title'> + <Logo /> + <h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3> + <p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p> + </div> + + {!isLoading && ( + <React.Fragment> + <div className='column-list'> + {suggestions.size > 0 ? suggestions.map(suggestion => ( + <Account key={suggestion.get('account')} id={suggestion.get('account')} /> + )) : ( + <div className='column-list__empty-message'> + <FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' /> + </div> + )} + </div> + + <div className='column-actions'> + <img src={imageGreeting} alt='' className='column-actions__background' /> + <Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button> + </div> + </React.Fragment> + )} + </div> + </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..eb9f3db7e --- /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' }, +}); + +export default @injectIntl +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 translate' 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..36a57d1d6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/follow_requests/index.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +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 AccountAuthorizeContainer from './containers/account_authorize_container'; +import { fetchFollowRequests, expandFollowRequests } from 'flavours/glitch/actions/accounts'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import { me } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), + isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true), + hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']), + locked: !!state.getIn(['accounts', me, 'locked']), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class FollowRequests extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list, + locked: PropTypes.bool, + domain: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchFollowRequests()); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandFollowRequests()); + }, 300, { leading: true }); + + render () { + const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props; + + if (!accountIds) { + return ( + <Column name='follow-requests'> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />; + const unlockedPrependMessage = locked ? null : ( + <div className='follow_requests-unlocked_explanation'> + <FormattedMessage + id='follow_requests.unlocked_explanation' + defaultMessage='Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.' + values={{ domain: domain }} + /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <ScrollableList + scrollKey='follow_requests' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + prepend={unlockedPrependMessage} + > + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} />, + )} + </ScrollableList> + </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..25f7f05b4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowers, + expandFollowers, +} from 'flavours/glitch/actions/accounts'; +import { FormattedMessage } from 'react-intl'; +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 ImmutablePureComponent from 'react-immutable-pure-component'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; + +const mapStateToProps = (state, props) => ({ + remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), + 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']), + isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true), +}); + +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + +export default @connect(mapStateToProps) +class Followers extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + if (!this.props.accountIds) { + 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(); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandFollowers(this.props.params.accountId)); + }, 300, { leading: true }); + + setRef = c => { + this.column = c; + } + + render () { + const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; + + if (!isAccount) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let emptyMessage; + + if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollableList + scrollKey='followers' + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} + alwaysPrepend + append={remoteMessage} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + </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..968829fd5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; +import { + fetchAccount, + fetchFollowing, + expandFollowing, +} from 'flavours/glitch/actions/accounts'; +import { FormattedMessage } from 'react-intl'; +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 ImmutablePureComponent from 'react-immutable-pure-component'; +import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; + +const mapStateToProps = (state, props) => ({ + remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), + 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']), + isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true), +}); + +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + +export default @connect(mapStateToProps) +class Following extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + if (!this.props.accountIds) { + 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(); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandFollowing(this.props.params.accountId)); + }, 300, { leading: true }); + + setRef = c => { + this.column = c; + } + + render () { + const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; + + if (!isAccount) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + let emptyMessage; + + if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + + return ( + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> + + <ScrollableList + scrollKey='following' + hasMore={hasMore} + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} + alwaysPrepend + append={remoteMessage} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + </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..4412adaed --- /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 fullPage /> + </Column> +); + +export default GenericNotFound; diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js new file mode 100644 index 000000000..4eebe83ef --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js @@ -0,0 +1,449 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ReactSwipeableViews from 'react-swipeable-views'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Icon from 'flavours/glitch/components/icon'; +import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; +import { autoPlayGif, reduceMotion } from 'flavours/glitch/util/initial_state'; +import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; +import { mascot } from 'flavours/glitch/util/initial_state'; +import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; +import classNames from 'classnames'; +import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; +import { assetHost } from 'flavours/glitch/util/config'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +class Content extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + announcement: ImmutablePropTypes.map.isRequired, + }; + + setRef = c => { + this.node = c; + } + + componentDidMount () { + this._updateLinks(); + } + + componentDidUpdate () { + this._updateLinks(); + } + + _updateLinks () { + 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.announcement.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 { + let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url')); + if (status) { + link.addEventListener('click', this.onStatusClick.bind(this, status), false); + } + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } + } + + onMentionClick = (mention, e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, ''); + + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/timelines/tag/${hashtag}`); + } + } + + onStatusClick = (status, e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${status.get('id')}`); + } + } + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + } + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + } + + render () { + const { announcement } = this.props; + + return ( + <div + className='announcements__item__content translate' + ref={this.setRef} + dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + /> + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + hovered: PropTypes.bool.isRequired, + }; + + render () { + const { emoji, emojiMap, hovered } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + <img + draggable='false' + className='emojione' + alt={emoji} + title={title} + src={`${assetHost}/emoji/${filename}.svg`} + /> + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + <img + draggable='false' + className='emojione custom-emoji' + alt={shortCode} + title={shortCode} + src={filename} + /> + ); + } else { + return null; + } + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + announcementId: PropTypes.string.isRequired, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, announcementId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(announcementId, reaction.get('name')); + } else { + addReaction(announcementId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render () { + const { reaction } = this.props; + + let shortCode = reaction.get('name'); + + if (unicodeMapping[shortCode]) { + shortCode = unicodeMapping[shortCode].shortCode; + } + + return ( + <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}> + <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span> + <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span> + </button> + ); + } + +} + +class ReactionsBar extends ImmutablePureComponent { + + static propTypes = { + announcementId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + }; + + handleEmojiPick = data => { + const { addReaction, announcementId } = this.props; + addReaction(announcementId, data.native.replace(/:/g, '')); + } + + willEnter () { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave () { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render () { + const { reactions } = this.props; + const visibleReactions = reactions.filter(x => x.get('count') > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> + {items.map(({ key, data, style }) => ( + <Reaction + key={key} + reaction={data} + style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} + announcementId={this.props.announcementId} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + emojiMap={this.props.emojiMap} + /> + ))} + + {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />} + </div> + )} + </TransitionMotion> + ); + } + +} + +class Announcement extends ImmutablePureComponent { + + static propTypes = { + announcement: ImmutablePropTypes.map.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + selected: PropTypes.bool, + }; + + state = { + unread: !this.props.announcement.get('read'), + }; + + componentDidUpdate () { + const { selected, announcement } = this.props; + if (!selected && this.state.unread !== !announcement.get('read')) { + this.setState({ unread: !announcement.get('read') }); + } + } + + render () { + const { announcement } = this.props; + const { unread } = this.state; + const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at')); + const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at')); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.get('all_day'); + + return ( + <div className='announcements__item'> + <strong className='announcements__item__range'> + <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' /> + {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>} + </strong> + + <Content announcement={announcement} /> + + <ReactionsBar + reactions={announcement.get('reactions')} + announcementId={announcement.get('id')} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + emojiMap={this.props.emojiMap} + /> + + {unread && <span className='announcements__item__unread' />} + </div> + ); + } + +} + +export default @injectIntl +class Announcements extends ImmutablePureComponent { + + static propTypes = { + announcements: ImmutablePropTypes.list, + emojiMap: ImmutablePropTypes.map.isRequired, + dismissAnnouncement: PropTypes.func.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + index: 0, + }; + + static getDerivedStateFromProps(props, state) { + if (props.announcements.size > 0 && state.index >= props.announcements.size) { + return { index: props.announcements.size - 1 }; + } else { + return null; + } + } + + componentDidMount () { + this._markAnnouncementAsRead(); + } + + componentDidUpdate () { + this._markAnnouncementAsRead(); + } + + _markAnnouncementAsRead () { + const { dismissAnnouncement, announcements } = this.props; + const { index } = this.state; + const announcement = announcements.get(index); + if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); + } + + handleChangeIndex = index => { + this.setState({ index: index % this.props.announcements.size }); + } + + handleNextClick = () => { + this.setState({ index: (this.state.index + 1) % this.props.announcements.size }); + } + + handlePrevClick = () => { + this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size }); + } + + render () { + const { announcements, intl } = this.props; + const { index } = this.state; + + if (announcements.isEmpty()) { + return null; + } + + return ( + <div className='announcements'> + <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} /> + + <div className='announcements__container'> + <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}> + {announcements.map((announcement, idx) => ( + <Announcement + key={announcement.get('id')} + announcement={announcement} + emojiMap={this.props.emojiMap} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + intl={intl} + selected={index === idx} + /> + ))} + </ReactSwipeableViews> + + {announcements.size > 1 && ( + <div className='announcements__pagination'> + <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} /> + <span>{index + 1} / {announcements.size}</span> + <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> + </div> + )} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js new file mode 100644 index 000000000..0734ec72b --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js @@ -0,0 +1,46 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Hashtag from 'flavours/glitch/components/hashtag'; +import { FormattedMessage } from 'react-intl'; + +export default class Trends extends ImmutablePureComponent { + + static defaultProps = { + loading: false, + }; + + static propTypes = { + trends: ImmutablePropTypes.list, + fetchTrends: PropTypes.func.isRequired, + }; + + componentDidMount () { + this.props.fetchTrends(); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000); + } + + componentWillUnmount () { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } + + render () { + const { trends } = this.props; + + if (!trends || trends.isEmpty()) { + return null; + } + + return ( + <div className='getting-started__trends'> + <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4> + + {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js new file mode 100644 index 000000000..d472323f8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements'; +import Announcements from '../components/announcements'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; + +const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); + +const mapStateToProps = state => ({ + announcements: state.getIn(['announcements', 'items']), + emojiMap: customEmojiMap(state), +}); + +const mapDispatchToProps = dispatch => ({ + dismissAnnouncement: id => dispatch(dismissAnnouncement(id)), + addReaction: (id, name) => dispatch(addReaction(id, name)), + removeReaction: (id, name) => dispatch(removeReaction(id, name)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Announcements); diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js new file mode 100644 index 000000000..68568d169 --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { fetchTrends } from 'flavours/glitch/actions/trends'; +import Trends from '../components/trends'; + +const mapStateToProps = state => ({ + trends: state.getIn(['trends', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchTrends: () => dispatch(fetchTrends()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Trends); 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..b4549fdf8 --- /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, profile_directory, showTrends } 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 } from 'flavours/glitch/util/backend_links'; +import NavigationBar from '../compose/components/navigation_bar'; +import LinkFooter from 'flavours/glitch/features/ui/components/link_footer'; +import TrendsContainer from './containers/trends_container'; + +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' }, + 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' }, + profile_directory: { id: 'getting_started.directory', defaultMessage: 'Profile directory' }, +}); + +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; + } +}; + +const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2); + + export default @connect(makeMapStateToProps, mapDispatchToProps) + @injectIntl + class GettingStarted extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + 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 { fetchFollowRequests, multiColumn } = this.props; + + if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) { + this.context.router.history.replace('/timelines/home'); + return; + } + + 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') || unreadFollowRequests > 0) { + navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); + } + + if (profile_directory) { + navItems.push(<ColumnLink key='7' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />); + } + + navItems.push(<ColumnLink key='8' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); + + listItems = listItems.concat([ + <div key='9'> + <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> + {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list => + <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> + )} + </div>, + ]); + + return ( + <Column bindToDocument={!multiColumn} 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'> + {!multiColumn && <NavigationBar account={myAccount} />} + {multiColumn && <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} /> + </div> + + <LinkFooter /> + </div> + + {multiColumn && showTrends && <TrendsContainer />} + </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..570fe78bf --- /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' }, +}); + +export default @connect() +@injectIntl +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..de1127b0d --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js @@ -0,0 +1,123 @@ +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/async'; +import { NonceProvider } from 'react-select'; +import SettingToggle from '../../notifications/components/setting_toggle'; + +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' }, +}); + +export default @injectIntl +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> + + <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'> + <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} + /> + </NonceProvider> + </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 () { + const { settings, onChange } = this.props; + + 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 className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} /> + </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..de1db692d --- /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, type: 'hashtags' } }).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..48e52e4cd --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -0,0 +1,165 @@ +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}${props.params.local ? ':local' : ''}`, 'unread']) > 0, +}); + +export default @connect(mapStateToProps) +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 = {}, local) { + 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, local, 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, local } = this.props.params; + + this._subscribe(dispatch, id, tags, local); + dispatch(expandHashtagTimeline(id, { tags, local })); + } + + componentWillReceiveProps (nextProps) { + const { dispatch, params } = this.props; + const { id, tags, local } = nextProps.params; + + if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) { + this._unsubscribe(); + this._subscribe(dispatch, id, tags, local); + dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`)); + dispatch(expandHashtagTimeline(id, { tags, local })); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { id, tags, local } = this.props.params; + this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local })); + } + + render () { + const { hasUnread, columnId, multiColumn } = this.props; + const { id, local } = 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 + bindToDocument={!multiColumn} + > + {columnId && <ColumnSettingsContainer columnId={columnId} />} + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`hashtag_timeline-${columnId}`} + timelineId={`hashtag:${id}${local ? ':local' : ''}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + bindToDocument={!multiColumn} + /> + </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..df615db65 --- /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' }, +}); + +export default @injectIntl +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..19551d6b8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -0,0 +1,162 @@ +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'; +import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements'; +import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container'; +import classNames from 'classnames'; +import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; + +const messages = defineMessages({ + title: { id: 'column.home', defaultMessage: 'Home' }, + show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' }, + hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, + isPartial: state.getIn(['timelines', 'home', 'isPartial']), + hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), + unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), + showAnnouncements: state.getIn(['announcements', 'show']), +}); + +export default @connect(mapStateToProps) +@injectIntl +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, + hasAnnouncements: PropTypes.bool, + unreadAnnouncements: PropTypes.number, + showAnnouncements: 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 () { + setTimeout(() => this.props.dispatch(fetchAnnouncements()), 700); + 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; + } + } + + handleToggleAnnouncementsClick = (e) => { + e.stopPropagation(); + this.props.dispatch(toggleShowAnnouncements()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; + const pinned = !!columnId; + + let announcementsButton = null; + + if (hasAnnouncements) { + announcementsButton = ( + <button + className={classNames('column-header__button', { 'active': showAnnouncements })} + title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)} + aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)} + aria-pressed={showAnnouncements ? 'true' : 'false'} + onClick={this.handleToggleAnnouncementsClick} + > + <IconWithBadge id='bullhorn' count={unreadAnnouncements} /> + </button> + ); + } + + return ( + <Column bindToDocument={!multiColumn} 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} + extraButton={announcementsButton} + appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />} + > + <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! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />} + bindToDocument={!multiColumn} + /> + </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..abc3f468f --- /dev/null +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js @@ -0,0 +1,139 @@ +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']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class KeyboardShortcuts extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + collapseEnabled: PropTypes.bool, + }; + + render () { + const { intl, collapseEnabled, multiColumn } = this.props; + + return ( + <Column bindToDocument={!multiColumn} 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>e</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td> + </tr> + <tr> + <td><kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> + </tr> + <tr> + <td><kbd>h</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></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>alt</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></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>alt</kbd>+<kbd>enter</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.secondary_toot' defaultMessage='to send toot using secondary privacy setting' /></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..4666ca47b --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_adder/components/list.js @@ -0,0 +1,69 @@ +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'; +import Icon from 'flavours/glitch/components/icon'; + +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'> + <Icon id='list-ul' className='column-link__icon' fixedWidth /> + {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..a8cab2762 --- /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']) || !state.getIn(['listEditor', 'title']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeListEditorTitle(value)), + onSubmit: () => dispatch(submitListEditor(false)), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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..192643f77 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/search.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; + +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}> + <Icon id='search' className={classNames({ active: !hasValue })} /> + <Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ 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..75b0de3d3 --- /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()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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..9e231aab7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_timeline/index.js @@ -0,0 +1,218 @@ +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'; +import Icon from 'flavours/glitch/components/icon'; +import RadioButton from 'flavours/glitch/components/radio_button'; + +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' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + list: { id: 'lists.replies_policy.list', 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, +}); + +export default @connect(mapStateToProps) +@injectIntl +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)); + } + + componentWillReceiveProps (nextProps) { + const { dispatch } = this.props; + const { id } = nextProps.params; + + if (id !== this.props.params.id) { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + + 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} + bindToDocument={!multiColumn} + > + <div className='column-settings__row column-header__links'> + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}> + <Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> + </button> + + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}> + <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> + </button> + </div> + + { replies_policy !== undefined && ( + <div role='group' aria-labelledby={`list-${id}-replies-policy`}> + <span id={`list-${id}-replies-policy`} className='column-settings__section'> + <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> + </span> + <div className='column-settings__row'> + { ['none', 'list', 'followed'].map(policy => ( + <RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> + ))} + </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.' />} + bindToDocument={!multiColumn} + /> + </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..cc78d30b7 --- /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)), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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 || !value} + 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..e384f301b --- /dev/null +++ b/app/javascript/flavours/glitch/features/lists/index.js @@ -0,0 +1,83 @@ +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, FormattedMessage } 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'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; + +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), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Lists extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchLists()); + } + + render () { + const { intl, lists, multiColumn } = this.props; + + if (!lists) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />; + + return ( + <Column bindToDocument={!multiColumn} icon='bars' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <NewListForm /> + + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + <ScrollableList + scrollKey='lists' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {lists.map(list => + <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />, + )} + </ScrollableList> + </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..ab3a554bf --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js @@ -0,0 +1,100 @@ +// 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' }, + filters: { id: 'settings.filters', defaultMessage: 'Filters' }, + 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' }, +}); + +export default @injectIntl +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='filter' + title={intl.formatMessage(messages.filters)} + /> + <LocalSettingsNavigationItem + active={index === 4} + index={4} + onNavigate={onNavigate} + icon='angle-double-up' + title={intl.formatMessage(messages.collapsed)} + /> + <LocalSettingsNavigationItem + active={index === 5} + index={5} + onNavigate={onNavigate} + icon='image' + title={intl.formatMessage(messages.media)} + /> + <LocalSettingsNavigationItem + active={index === 6} + href={ preferencesLink } + index={6} + icon='cog' + title={intl.formatMessage(messages.preferences)} + /> + <LocalSettingsNavigationItem + active={index === 7} + className='close' + index={7} + 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..4dec7d154 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js @@ -0,0 +1,70 @@ +// Package imports +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import Icon from 'flavours/glitch/components/icon'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +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 ? <Icon fixedWidth id={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..45d10d154 --- /dev/null +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -0,0 +1,458 @@ +// 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_auto_hint: { id: 'layout.hint.auto', defaultMessage: 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.' }, + layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' }, + layout_desktop_hint: { id: 'layout.hint.desktop', defaultMessage: 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.' }, + layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' }, + layout_mobile_hint: { id: 'layout.hint.single', defaultMessage: 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.' }, + 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' }, + filters_drop: { id: 'settings.filtering_behavior.drop', defaultMessage: 'Hide filtered toots completely' }, + filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' }, + filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' }, + filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' }, + rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' }, + rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' }, + rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage: 'Rewrite with username' }, + pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' }, + pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' }, +}); + +export default @injectIntl +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> + <LocalSettingsPageItem + settings={settings} + item={['confirm_boost_missing_media_description']} + id='mastodon-settings--confirm_boost_missing_media_description' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['tag_misleading_links']} + id='mastodon-settings--tag_misleading_links' + onChange={onChange} + > + <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' /> + <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage="Add a visual indication with the link target host to every link not mentioning it explicitly" /></span> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['rewrite_mentions']} + id='mastodon-settings--rewrite_mentions' + options={[ + { value: 'no', message: intl.formatMessage(messages.rewrite_mentions_no) }, + { value: 'acct', message: intl.formatMessage(messages.rewrite_mentions_acct) }, + { value: 'username', message: intl.formatMessage(messages.rewrite_mentions_username) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.rewrite_mentions' defaultMessage='Rewrite mentions in displayed statuses' /> + </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), hint: intl.formatMessage(messages.layout_auto_hint) }, + { value: 'multiple', message: intl.formatMessage(messages.layout_desktop), hint: intl.formatMessage(messages.layout_desktop_hint) }, + { value: 'single', message: intl.formatMessage(messages.layout_mobile), hint: intl.formatMessage(messages.layout_mobile_hint) }, + ]} + 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={['prepend_cw_re']} + id='mastodon-settings--prepend_cw_re' + onChange={onChange} + > + <FormattedMessage id='settings.prepend_cw_re' defaultMessage='Prepend “re: ” to content warnings when replying' /> + </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={['show_content_type_choice']} + id='mastodon-settings--show_content_type_choice' + onChange={onChange} + > + <FormattedMessage id='settings.show_content_type_choice' defaultMessage='Show content-type choice when authoring toots' /> + </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> + ), + ({ intl, onChange, settings }) => ( + <div className='glitch local-settings__page filters'> + <h1><FormattedMessage id='settings.filters' defaultMessage='Filters' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['filtering_behavior']} + id='mastodon-settings--filters-behavior' + onChange={onChange} + options={[ + { value: 'drop', message: intl.formatMessage(messages.filters_drop) }, + { value: 'upstream', message: intl.formatMessage(messages.filters_upstream) }, + { value: 'hide', message: intl.formatMessage(messages.filters_hide) }, + { value: 'content_warning', message: intl.formatMessage(messages.filters_cw) } + ]} + > + <FormattedMessage id='settings.filtering_behavior' defaultMessage='Filtering behavior' /> + </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> + ), + ({ intl, 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> + <LocalSettingsPageItem + settings={settings} + item={['media', 'pop_in_player']} + id='mastodon-settings--pop-in-player' + onChange={onChange} + > + <FormattedMessage id='settings.pop_in_player' defaultMessage='Enable pop-in player' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'pop_in_position']} + id='mastodon-settings--pop-in-position' + options={[ + { value: 'left', message: intl.formatMessage(messages.pop_in_left) }, + { value: 'right', message: intl.formatMessage(messages.pop_in_right) }, + ]} + onChange={onChange} + dependsOn={[['media', 'pop_in_player']]} + > + <FormattedMessage id='settings.pop_in_position' defaultMessage='Pop-in player position:' /> + </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..5a68523f6 --- /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.node.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 className='hint'>{opt.hint}</span>} + </label> + ); + }); + return ( + <div className='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..9f0d5a43e --- /dev/null +++ b/app/javascript/flavours/glitch/features/mutes/index.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +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 AccountContainer from 'flavours/glitch/containers/account_container'; +import { fetchMutes, expandMutes } from 'flavours/glitch/actions/mutes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'mutes', 'items']), + hasMore: !!state.getIn(['user_lists', 'mutes', 'next']), + isLoading: state.getIn(['user_lists', 'mutes', 'isLoading'], true), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Mutes extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchMutes()); + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandMutes()); + }, 300, { leading: true }); + + render () { + const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />; + + return ( + <Column bindToDocument={!multiColumn} name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollableList + scrollKey='mutes' + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + isLoading={isLoading} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} />, + )} + </ScrollableList> + </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..ee77cfb8e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +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}><Icon id='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..c01a21e3b --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -0,0 +1,160 @@ +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 GrantPermissionButton from './grant_permission_button'; +import SettingToggle from './setting_toggle'; +import PillBarButton from './pill_bar_button'; + +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, + onRequestNotificationPermission: PropTypes.func, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, + }; + + onPushChange = (path, checked) => { + this.props.onChange(['push', ...path], checked); + } + + render () { + const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = 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' />; + + return ( + <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + + {alertsEnabled && browserSupport && browserPermission === 'default' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'> + <FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} /> + </span> + </div> + )} + + <div className='column-settings__row'> + <ClearColumnButton onClick={onClear} /> + </div> + + <div role='group' aria-labelledby='notifications-unread-markers'> + <span id='notifications-unread-markers' className='column-settings__section'> + <FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' /> + </span> + + <div className='column-settings__row'> + <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} /> + </div> + </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__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-follow-request'> + <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} 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__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <PillBarButton 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__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> + <PillBarButton 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__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <PillBarButton 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__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} 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..c1de0f90e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +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' }, + statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, +}); + +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)} + > + <Icon id='reply-all' fixedWidth /> + </button> + <button + className={selectedFilter === 'favourite' ? 'active' : ''} + onClick={this.onClick('favourite')} + title={intl.formatMessage(tooltips.favourites)} + > + <Icon id='star' fixedWidth /> + </button> + <button + className={selectedFilter === 'reblog' ? 'active' : ''} + onClick={this.onClick('reblog')} + title={intl.formatMessage(tooltips.boosts)} + > + <Icon id='retweet' fixedWidth /> + </button> + <button + className={selectedFilter === 'poll' ? 'active' : ''} + onClick={this.onClick('poll')} + title={intl.formatMessage(tooltips.polls)} + > + <Icon id='tasks' fixedWidth /> + </button> + <button + className={selectedFilter === 'status' ? 'active' : ''} + onClick={this.onClick('status')} + title={intl.formatMessage(tooltips.statuses)} + > + <Icon id='home' fixedWidth /> + </button> + <button + className={selectedFilter === 'follow' ? 'active' : ''} + onClick={this.onClick('follow')} + title={intl.formatMessage(tooltips.follows)} + > + <Icon id='user-plus' fixedWidth /> + </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..0d3162fc9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -0,0 +1,101 @@ +// 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'; +import classNames from 'classnames'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + }; + + 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, unread } = 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={classNames('notification notification-follow focusable', { unread })} tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon fixedWidth id='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/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js new file mode 100644 index 000000000..f351c1035 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js @@ -0,0 +1,132 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import { HotKeys } from 'react-hotkeys'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +export default @injectIntl +class FollowRequest extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + }; + + 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 { intl, hidden, account, onAuthorize, onReject, notification, unread } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <Fragment> + {account.get('display_name')} + {account.get('username')} + </Fragment> + ); + } + + // 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> + ); + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='user' fixedWidth /> + </div> + + <FormattedMessage + id='notification.follow_request' + defaultMessage='{name} has requested to follow you' + values={{ name: link }} + /> + </div> + + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__relationship'> + <IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /> + <IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /> + </div> + </div> + </div> + + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.js b/app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.js new file mode 100644 index 000000000..798e4c787 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/grant_permission_button.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default class GrantPermissionButton extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__permission-btn' tabIndex='0' onClick={this.props.onClick}> + <FormattedMessage id='notifications.grant_permission' defaultMessage='Grant permission.' /> + </button> + ); + } + +} 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..e1d9fbd0a --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -0,0 +1,179 @@ +// 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'; +import NotificationFollowRequestContainer from '../containers/follow_request_container'; + +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, + unread: PropTypes.bool, + }; + + 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} + unread={this.props.unread} + /> + ); + case 'follow_request': + return ( + <NotificationFollowRequestContainer + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + unread={this.props.unread} + /> + ); + 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 + unread={this.props.unread} + /> + ); + case 'status': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='status' + 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 + unread={this.props.unread} + /> + ); + 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 + unread={this.props.unread} + /> + ); + 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 + unread={this.props.unread} + /> + ); + 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 + unread={this.props.unread} + /> + ); + default: + return null; + } + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js new file mode 100644 index 000000000..dd163225e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js @@ -0,0 +1,48 @@ +import React from 'react'; +import Icon from 'flavours/glitch/components/icon'; +import Button from 'flavours/glitch/components/button'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { requestBrowserPermission } from 'flavours/glitch/actions/notifications'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @connect() +@injectIntl +class NotificationsPermissionBanner extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.dispatch(requestBrowserPermission()); + } + + handleClose = () => { + this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true)); + } + + render () { + const { intl } = this.props; + + return ( + <div className='notifications-permission-banner'> + <div className='notifications-permission-banner__close'> + <IconButton icon='times' onClick={this.handleClose} title={intl.formatMessage(messages.close)} /> + </div> + + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} 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..f3ccafc06 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js @@ -0,0 +1,58 @@ +/** + * 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'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +export default @injectIntl +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 ? (<Icon id='check' />) : ''} + </div> + </div> + </div> + ) : null; + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js b/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js new file mode 100644 index 000000000..223b7f75f --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import classNames from 'classnames' + +export default class PillBarButton extends React.PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + } + + onChange = () => { + const { settings, settingPath } = this.props; + this.props.onChange(settingPath, !settings.getIn(settingPath)); + } + + render () { + const { prefix, settings, settingPath, label, disabled } = this.props; + const id = ['setting-pillbar-button', prefix, ...settingPath].filter(Boolean).join('-'); + const active = settings.getIn(settingPath); + + return ( + <button + key={id} + id={id} + className={classNames('pillbar-button', { active })} + disabled={disabled} + onClick={this.onChange} + aria-pressed={active} + > + {label} + </button> + ); + } + +} 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..e472f7c4f --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -0,0 +1,36 @@ +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, + defaultValue: PropTypes.bool, + disabled: PropTypes.bool, + } + + onChange = ({ target }) => { + this.props.onChange(this.props.settingPath, target.checked); + } + + render () { + const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props; + const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); + + return ( + <div className='setting-toggle'> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} 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..c2564f44e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js @@ -0,0 +1,74 @@ +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, requestBrowserPermission } from 'flavours/glitch/actions/notifications'; +import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { showAlert } from 'flavours/glitch/actions/alerts'; + +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' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, +}); + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (path, checked) { + if (path[0] === 'push') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } + } else if (path[0] === 'quickFilter') { + dispatch(changeSetting(['notifications', ...path], checked)); + dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } + }, + + onClear () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()), + })); + }, + + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + +}); + +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/follow_request_container.js b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js new file mode 100644 index 000000000..82357adfb --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import FollowRequest from '../components/follow_request'; +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts'; + +const mapDispatchToProps = (dispatch, { account }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(account.get('id'))); + }, + + onReject () { + dispatch(rejectFollowRequest(account.get('id'))); + }, +}); + +export default connect(null, mapDispatchToProps)(FollowRequest); 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..6fc951e37 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -0,0 +1,357 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; +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, + loadPending, + markNotificationsAsRead, +} from 'flavours/glitch/actions/notifications'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { submitMarkers } from 'flavours/glitch/actions/markers'; +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'; +import Icon from 'flavours/glitch/components/icon'; +import compareId from 'flavours/glitch/util/compare_id'; +import NotificationsPermissionBanner from './components/notifications_permission_banner'; + +import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, + markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, +}); + +const getExcludedTypes = createSelector([ + state => state.getIn(['settings', 'notifications', 'shows']), +], (shows) => { + return ImmutableList(shows.filter(item => !item).keys()); +}); + +const getNotifications = createSelector([ + state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), + state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + getExcludedTypes, + 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 || state.getIn(['notifications', 'pendingItems']).size > 0, + hasMore: state.getIn(['notifications', 'hasMore']), + numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), + lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0', + canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']), +}); + +/* glitch */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + onMarkAsRead() { + dispatch(markNotificationsAsRead()); + dispatch(submitMarkers({ immediate: true })); + }, + onMount() { + dispatch(mountNotifications()); + }, + onUnmount() { + dispatch(unmountNotifications()); + }, + dispatch, +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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, + numPending: PropTypes.number, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + lastReadId: PropTypes.string, + canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + animatingNCD: false, + }; + + 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 }); + + handleLoadPending = () => { + this.props.dispatch(loadPending()); + }; + + 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(); + } + } + + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + } + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + } + + handleMarkAsRead = () => { + this.props.onMarkAsRead(); + } + + render () { + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; + const { notifCleaning, notifCleaningActive } = this.props; + const { animatingNCD } = this.state; + const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />; + + 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} + unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0} + /> + )); + } else { + scrollableContent = null; + } + + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + showLoading={isLoading && notifications.size === 0} + hasMore={hasMore} + numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend + emptyMessage={emptyMessage} + onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} + > + {scrollableContent} + </ScrollableList> + ); + + const extraButtons = []; + + if (canMarkAsRead) { + extraButtons.push( + <button + aria-label={intl.formatMessage(messages.markAsRead)} + title={intl.formatMessage(messages.markAsRead)} + onClick={this.handleMarkAsRead} + className='column-header__button' + > + <Icon id='check' /> + </button> + ); + } + + const notifCleaningButtonClassName = classNames('column-header__button', { + 'active': notifCleaningActive, + }); + + const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { + 'collapsed': !notifCleaningActive, + 'animating': animatingNCD, + }); + + const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); + + extraButtons.push( + <button + aria-label={msgEnterNotifCleaning} + title={msgEnterNotifCleaning} + onClick={this.onEnterCleaningMode} + className={notifCleaningButtonClassName} + > + <Icon id='eraser' /> + </button> + ); + + const notifCleaningDrawer = ( + <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> + <div className='column-header__collapsible-inner nopad-drawer'> + {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } + </div> + </div> + ); + + return ( + <Column + bindToDocument={!multiColumn} + 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} + extraButton={extraButtons} + appendContent={notifCleaningDrawer} + > + <ColumnSettingsContainer /> + </ColumnHeader> + {filterBarContainer} + {scrollContainer} + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js new file mode 100644 index 000000000..fcb2df527 --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js @@ -0,0 +1,185 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import classNames from 'classnames'; +import { me, boostModal } from 'flavours/glitch/util/initial_state'; +import { defineMessages, injectIntl } from 'react-intl'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { initBoostModal } from 'flavours/glitch/actions/boosts'; + +const messages = defineMessages({ + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + 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?' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { statusId }) => ({ + status: getStatus(state, { id: statusId }), + askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, + showReplyCount: state.getIn(['local_settings', 'show_reply_count']), + }); + + return mapStateToProps; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +class Footer extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + statusId: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + askReplyConfirmation: PropTypes.bool, + showReplyCount: PropTypes.bool, + withOpenButton: PropTypes.bool, + onClose: PropTypes.func, + }; + + _performReply = () => { + const { dispatch, status, onClose } = this.props; + const { router } = this.context; + + if (onClose) { + onClose(); + } + + dispatch(replyCompose(status, router.history)); + }; + + handleReplyClick = () => { + const { dispatch, askReplyConfirmation, intl } = this.props; + + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: this._performReply, + })); + } else { + this._performReply(); + } + }; + + handleFavouriteClick = () => { + const { dispatch, status } = this.props; + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + _performReblog = (privacy) => { + const { dispatch, status } = this.props; + dispatch(reblog(status, privacy)); + } + + handleReblogClick = e => { + const { dispatch, status } = this.props; + + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else if ((e && e.shiftKey) || !boostModal) { + this._performReblog(); + } else { + dispatch(initBoostModal({ status, onReblog: this._performReblog })); + } + }; + + handleOpenClick = e => { + const { router } = this.context; + + if (e.button !== 0 || !router) { + return; + } + + const { status } = this.props; + + router.history.push(`/statuses/${status.get('id')}`); + } + + render () { + const { status, intl, showReplyCount, withOpenButton } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let replyIcon, replyTitle; + + 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); + } + + let reblogTitle = ''; + + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_reblog); + } + + let replyButton = null; + if (showReplyCount) { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} + onClick={this.handleReplyClick} + counter={status.get('replies_count')} + obfuscateCount + /> + ); + } else { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} + onClick={this.handleReplyClick} + /> + ); + } + + return ( + <div className='picture-in-picture__footer'> + {replyButton} + <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> + <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} counter={status.get('favourites_count')} /> + {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js new file mode 100644 index 000000000..28526ca88 --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { Link } from 'react-router-dom'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const mapStateToProps = (state, { accountId }) => ({ + account: state.getIn(['accounts', accountId]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Header extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { account, statusId, onClose, intl } = this.props; + + return ( + <div className='picture-in-picture__header'> + <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'> + <Avatar account={account} size={36} /> + <DisplayName account={account} /> + </Link> + + <IconButton icon='times' onClick={onClose} title={intl.formatMessage(messages.close)} /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.js b/app/javascript/flavours/glitch/features/picture_in_picture/index.js new file mode 100644 index 000000000..3e6a20faa --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import Header from './components/header'; +import Footer from './components/footer'; +import classNames from 'classnames'; + +const mapStateToProps = state => ({ + ...state.get('picture_in_picture'), + left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left', +}); + +export default @connect(mapStateToProps) +class PictureInPicture extends React.Component { + + static propTypes = { + statusId: PropTypes.string, + accountId: PropTypes.string, + type: PropTypes.string, + src: PropTypes.string, + muted: PropTypes.bool, + volume: PropTypes.number, + currentTime: PropTypes.number, + poster: PropTypes.string, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, + dispatch: PropTypes.func.isRequired, + left: PropTypes.bool, + }; + + handleClose = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + render () { + const { type, src, currentTime, accountId, statusId, left } = this.props; + + if (!currentTime) { + return null; + } + + let player; + + if (type === 'video') { + player = ( + <Video + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + autoPlay + inline + alwaysVisible + /> + ); + } else if (type === 'audio') { + player = ( + <Audio + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + poster={this.props.poster} + backgroundColor={this.props.backgroundColor} + foregroundColor={this.props.foregroundColor} + accentColor={this.props.accentColor} + autoPlay + /> + ); + } + + return ( + <div className={classNames('picture-in-picture', { left })}> + <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> + + {player} + + <Footer statusId={statusId} /> + </div> + ); + } + +} 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..5f03c7e93 --- /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()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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..34d8e465f --- /dev/null +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js @@ -0,0 +1,61 @@ +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']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class PinnedStatuses extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + hasMore: PropTypes.bool.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchPinnedStatuses()); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, statusIds, hasMore, multiColumn } = this.props; + + return ( + <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> + <ColumnBackButtonSlim /> + <StatusList + statusIds={statusIds} + scrollKey='pinned_statuses' + hasMore={hasMore} + bindToDocument={!multiColumn} + /> + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js new file mode 100644 index 000000000..e92681065 --- /dev/null +++ b/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import SettingToggle from '../../notifications/components/setting_toggle'; + +export default @injectIntl +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 } = 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' />} /> + <SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} /> + {!settings.getIn(['other', 'onlyRemote']) && <SettingToggle settings={settings} settingPath={['other', 'allowLocalOnly']} onChange={onChange} label={<FormattedMessage id='community.column_settings.allow_local_only' defaultMessage='Show local-only toots' />} />} + </div> + </div> + ); + } + +} 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..5091bfb90 --- /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 '../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..848049965 --- /dev/null +++ b/app/javascript/flavours/glitch/features/public_timeline/index.js @@ -0,0 +1,141 @@ +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, { columnId }) => { + const uuid = columnId; + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === uuid); + const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']); + const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']); + const allowLocalOnly = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'allowLocalOnly']) : state.getIn(['settings', 'public', 'other', 'allowLocalOnly']); + const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]); + + return { + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, + onlyRemote, + allowLocalOnly, + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +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, + onlyRemote: PropTypes.bool, + allowLocalOnly: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote, allowLocalOnly } })); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); + this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); + } + + componentDidUpdate (prevProps) { + if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote || prevProps.allowLocalOnly !== this.props.allowLocalOnly) { + const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + this.disconnect(); + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); + this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); + } + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + + dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote, allowLocalOnly })); + } + + render () { + const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + const pinned = !!columnId; + + return ( + <Column bindToDocument={!multiColumn} 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${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${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' />} + bindToDocument={!multiColumn} + /> + </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..d88916d19 --- /dev/null +++ b/app/javascript/flavours/glitch/features/reblogs/index.js @@ -0,0 +1,99 @@ +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 AccountContainer from 'flavours/glitch/containers/account_container'; +import Column from 'flavours/glitch/features/ui/components/column'; +import Icon from 'flavours/glitch/components/icon'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' }, + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, +}); + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Reblogs extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + if (!this.props.accountIds) { + 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)); + } + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleRefresh = () => { + this.props.dispatch(fetchReblogs(this.props.params.statusId)); + } + + render () { + const { intl, accountIds, multiColumn } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this toot yet. When someone does, they will show up here.' />; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='retweet' + title={intl.formatMessage(messages.heading)} + onClick={this.handleHeaderClick} + showBackButton + multiColumn={multiColumn} + extraButton={( + <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button> + )} + /> + + <ScrollableList + scrollKey='reblogs' + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {accountIds.map(id => + <AccountContainer key={id} id={id} withNote={false} />, + )} + </ScrollableList> + </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/search/index.js b/app/javascript/flavours/glitch/features/search/index.js new file mode 100644 index 000000000..b35c8ed49 --- /dev/null +++ b/app/javascript/flavours/glitch/features/search/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import SearchContainer from 'flavours/glitch/features/compose/containers/search_container'; +import SearchResultsContainer from 'flavours/glitch/features/compose/containers/search_results_container'; + +const Search = () => ( + <div className='column search-page'> + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner darker'> + <SearchResultsContainer /> + </div> + </div> + </div> +); + +export default Search; 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..629f5c2ea --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js @@ -0,0 +1,90 @@ +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, + local: PropTypes.bool.isRequired, + }; + + static defaultProps = { + local: false, + }; + + componentDidMount () { + const { dispatch, hashtag, local } = this.props; + + dispatch(expandHashtagTimeline(hashtag, { local })); + } + + handleLoadMore = () => { + const { dispatch, hashtag, local, statusIds } = this.props; + const maxId = statusIds.last(); + + if (maxId) { + dispatch(expandHashtagTimeline(hashtag, { maxId, local })); + } + } + + 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..6ed5f3865 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -0,0 +1,226 @@ +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'; +import classNames from 'classnames'; + +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 with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + more: { id: 'status.more', defaultMessage: 'More' }, + 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' }, +}); + +export default @injectIntl +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'); + const writtenByMe = status.getIn(['account', 'id']) === me; + + 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 (writtenByMe) { + 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) && publicStatus && ( + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div> + ); + + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let reblogTitle; + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_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 className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' 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' title={intl.formatMessage(messages.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..14abe9838 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -0,0 +1,281 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Immutable from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import punycode from 'punycode'; +import classnames from 'classnames'; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; +import Icon from 'flavours/glitch/components/icon'; +import { useBlurhash } from 'flavours/glitch/util/initial_state'; +import Blurhash from 'flavours/glitch/components/blurhash'; +import { debounce } from 'lodash'; + +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, + sensitive: PropTypes.bool, + }; + + static defaultProps = { + maxDescription: 50, + compact: false, + }; + + state = { + width: this.props.defaultWidth || 280, + previewLoaded: false, + embedded: false, + revealed: !this.props.sensitive, + }; + + componentWillReceiveProps (nextProps) { + if (!Immutable.is(this.props.card, nextProps.card)) { + this.setState({ embedded: false, previewLoaded: false }); + } + if (this.props.sensitive !== nextProps.sensitive) { + this.setState({ revealed: !nextProps.sensitive }); + } + } + + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + _setDimensions () { + const width = this.node.offsetWidth; + + if (this.props.cacheWidth) { + this.props.cacheWidth(width); + } + + this.setState({ width }); + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + 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 => { + this.node = c; + + if (this.node) { + this._setDimensions(); + } + } + + handleImageLoad = () => { + this.setState({ previewLoaded: true }); + } + + handleReveal = e => { + e.preventDefault(); + e.stopPropagation(); + this.setState({ revealed: true }); + } + + 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, revealed } = 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 noreferrer' 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 canvas = ( + <Blurhash + className={classnames('status-card__image-preview', { + 'status-card__image-preview--hidden': revealed && this.state.previewLoaded, + })} + hash={card.get('blurhash')} + dummy={!useBlurhash} + /> + ); + let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />; + let spoilerButton = ( + <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + </button> + ); + spoilerButton = ( + <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}> + {spoilerButton} + </div> + ); + + 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'> + {canvas} + {thumbnail} + + {revealed && ( + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + </div> + </div> + )} + {!revealed && spoilerButton} + </div> + ); + } + + return ( + <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}> + {embed} + {!compact && description} + </div> + ); + } else if (card.get('image')) { + embed = ( + <div className='status-card__image'> + {canvas} + {thumbnail} + </div> + ); + } else { + embed = ( + <div className='status-card__image'> + <Icon id='file-text' /> + </div> + ); + } + + return ( + <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' 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..4cc1d1af5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -0,0 +1,292 @@ +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 } from 'react-intl'; +import Card from './card'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +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'; +import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; + +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, + showMedia: PropTypes.bool, + usingPiP: PropTypes.bool, + onToggleMediaVisibility: PropTypes.func, + }; + + 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 = (options) => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options); + } + + _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, usingPiP } = 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 (usingPiP) { + media = <PictureInPicturePlaceholder />; + mediaIcon = 'video-camera'; + } 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']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Audio + src={attachment.get('url')} + alt={attachment.get('description')} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} + backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} + foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} + accentColor={attachment.getIn(['meta', 'colors', 'accent'])} + height={150} + /> + ); + mediaIcon = 'music'; + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + media = ( + <Video + preview={attachment.get('preview_url')} + frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={attachment.get('description')} + inline + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + preventPlayback={!expanded} + onOpenVideo={this.handleOpenVideo} + autoplay + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} + /> + ); + 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} + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} + /> + ); + mediaIcon = 'picture-o'; + } + } else if (status.get('card')) { + media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />; + mediaIcon = 'link'; + } + + if (status.get('application')) { + applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>; + } + + const visibilityLink = <React.Fragment> · <VisibilityIcon visibility={status.get('visibility')} /></React.Fragment>; + + if (status.get('visibility') === 'direct') { + reblogIcon = 'envelope'; + } else if (status.get('visibility') === 'private') { + reblogIcon = 'lock'; + } + + if (!['unlisted', 'public'].includes(status.get('visibility'))) { + reblogLink = null; + } else if (this.context.router) { + reblogLink = ( + <React.Fragment> + <React.Fragment> · </React.Fragment> + <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> + <Icon id={reblogIcon} /> + <span className='detailed-status__reblogs'> + <AnimatedNumber value={status.get('reblogs_count')} /> + </span> + </Link> + </React.Fragment> + ); + } else { + reblogLink = ( + <React.Fragment> + <React.Fragment> · </React.Fragment> + <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> + <Icon id={reblogIcon} /> + <span className='detailed-status__reblogs'> + <AnimatedNumber value={status.get('reblogs_count')} /> + </span> + </a> + </React.Fragment> + ); + } + + if (this.context.router) { + favouriteLink = ( + <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <Icon id='star' /> + <span className='detailed-status__favorites'> + <AnimatedNumber value={status.get('favourites_count')} /> + </span> + </Link> + ); + } else { + favouriteLink = ( + <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> + <Icon id='star' /> + <span className='detailed-status__favorites'> + <AnimatedNumber value={status.get('favourites_count')} /> + </span> + </a> + ); + } + + return ( + <div style={outerStyle}> + <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { 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} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} + disabled + /> + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} + </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..40e186569 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js @@ -0,0 +1,161 @@ +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 { + muteStatus, + unmuteStatus, + deleteStatus, + hideStatus, + revealStatus, +} from 'flavours/glitch/actions/statuses'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { initBoostModal } from 'flavours/glitch/actions/boosts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { defineMessages, injectIntl } 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.' }, + 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?' }, +}); + +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, privacy) { + dispatch(reblog(status, privacy)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !boostModal) { + this.onModalReblog(status); + } else { + dispatch(initBoostModal({ 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, options) { + dispatch(openModal('VIDEO', { media, options })); + }, + + onBlock (status) { + const account = status.get('account'); + dispatch(initBlockModal(account)); + }, + + 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..513a6227f --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -0,0 +1,612 @@ +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 { createSelector } from 'reselect'; +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 { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; +import { initBlockModal } from 'flavours/glitch/actions/blocks'; +import { initReport } from 'flavours/glitch/actions/reports'; +import { initBoostModal } from 'flavours/glitch/actions/boosts'; +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 } 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, defaultMediaVisibility } from 'flavours/glitch/components/status'; +import Icon from 'flavours/glitch/components/icon'; + +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.' }, + 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?' }, + tootHeading: { id: 'column.toot', defaultMessage: 'Toots and replies' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const getAncestorsIds = createSelector([ + (_, { id }) => id, + state => state.getIn(['contexts', 'inReplyTos']), + ], (statusId, inReplyTos) => { + let ancestorsIds = Immutable.List(); + ancestorsIds = ancestorsIds.withMutations(mutable => { + let id = statusId; + + while (id) { + mutable.unshift(id); + id = inReplyTos.get(id); + } + }); + + return ancestorsIds; + }); + + const getDescendantsIds = createSelector([ + (_, { id }) => id, + state => state.getIn(['contexts', 'replies']), + state => state.get('statuses'), + ], (statusId, contextReplies, statuses) => { + let descendantsIds = []; + const ids = [statusId]; + + while (ids.length > 0) { + let id = ids.shift(); + const replies = contextReplies.get(id); + + if (statusId !== id) { + descendantsIds.push(id); + } + + if (replies) { + replies.reverse().forEach(reply => { + ids.unshift(reply); + }); + } + } + + let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account')); + if (insertAt !== -1) { + descendantsIds.forEach((id, idx) => { + if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) { + descendantsIds.splice(idx, 1); + descendantsIds.splice(insertAt, 0, id); + insertAt += 1; + } + }); + } + + return Immutable.List(descendantsIds); + }); + + const mapStateToProps = (state, props) => { + const status = getStatus(state, { id: props.params.statusId }); + let ancestorsIds = Immutable.List(); + let descendantsIds = Immutable.List(); + + if (status) { + ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); + descendantsIds = getDescendantsIds(state, { id: status.get('id') }); + } + + 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']), + usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, + }; + }; + + return mapStateToProps; +}; + +export default @injectIntl +@connect(makeMapStateToProps) +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, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + usingPiP: PropTypes.bool, + }; + + state = { + fullscreen: false, + isExpanded: undefined, + threadExpanded: undefined, + statusId: undefined, + loadedStatusId: undefined, + showMedia: undefined, + revealBehindCW: 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) { + let update = {}; + let updated = false; + + if (props.params.statusId && state.statusId !== props.params.statusId) { + props.dispatch(fetchStatus(props.params.statusId)); + update.threadExpanded = undefined; + update.statusId = props.params.statusId; + updated = true; + } + + const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']); + if (revealBehindCW !== state.revealBehindCW) { + update.revealBehindCW = revealBehindCW; + if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings); + updated = true; + } + + if (props.status && state.loadedStatusId !== props.status.get('id')) { + update.showMedia = defaultMediaVisibility(props.status, props.settings); + update.loadedStatusId = props.status.get('id'); + update.isExpanded = autoUnfoldCW(props.settings, props.status); + updated = true; + } + + return updated ? update : null; + } + + handleExpandedToggle = () => { + if (this.props.status.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + + 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, privacy) => { + const { dispatch } = this.props; + + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status, privacy)); + } + } + + handleReblogClick = (status, e) => { + const { settings, dispatch } = this.props; + + if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { + dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true })); + } else if ((e && e.shiftKey) || !boostModal) { + this.handleModalReblog(status); + } else { + dispatch(initBoostModal({ 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', { statusId: this.props.status.get('id'), media, index })); + } + + handleOpenVideo = (media, options) => { + this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options })); + } + + handleHotkeyOpenMedia = e => { + const { status } = this.props; + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); + } else { + this.handleOpenMedia(status.get('media_attachments'), 0); + } + } + } + + 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 } = this.props; + const account = status.get('account'); + dispatch(initBlockModal(account)); + } + + handleReport = (status) => { + this.props.dispatch(initReport(status.get('account'), status)); + } + + handleEmbed = (status) => { + this.props.dispatch(openModal('EMBED', { url: status.get('url') })); + } + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + + 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, multiColumn, usingPiP } = this.props; + const { fullscreen, isExpanded } = this.state; + + if (status === null) { + return ( + <Column> + <ColumnBackButton multiColumn={multiColumn} /> + <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, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + return ( + <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}> + <ColumnHeader + icon='comment' + title={intl.formatMessage(messages.tootHeading)} + onClick={this.handleHeaderClick} + showBackButton + multiColumn={multiColumn} + 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'}><Icon id={!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, isExpanded)}> + <DetailedStatus + key={`details-${status.get('id')}`} + status={status} + settings={settings} + onOpenVideo={this.handleOpenVideo} + onOpenMedia={this.handleOpenMedia} + expanded={isExpanded} + onToggleHidden={this.handleExpandedToggle} + domain={domain} + showMedia={this.state.showMedia} + onToggleMediaVisibility={this.handleToggleMediaVisibility} + usingPiP={usingPiP} + /> + + <ActionBar + key={`action-bar-${status.get('id')}`} + 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..24169036c --- /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' + fixedWidth + id={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 noreferrer'> + <RelativeTimestamp timestamp={this.props.status.get('created_at')} /> + </a> + </div> + + <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name' rel='noopener noreferrer'> + <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/audio_modal.js b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js new file mode 100644 index 000000000..fc98cc6af --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js @@ -0,0 +1,58 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Audio from 'flavours/glitch/features/audio'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Footer from 'flavours/glitch/features/picture_in_picture/components/footer'; + +const mapStateToProps = (state, { statusId }) => ({ + accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), +}); + +export default @connect(mapStateToProps) +class AudioModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + statusId: PropTypes.string.isRequired, + accountStaticAvatar: PropTypes.string.isRequired, + options: PropTypes.shape({ + autoPlay: PropTypes.bool, + }), + onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + }; + + static contextTypes = { + router: PropTypes.object, + }; + + render () { + const { media, accountStaticAvatar, statusId, onClose } = this.props; + const options = this.props.options || {}; + + return ( + <div className='modal-root__modal audio-modal'> + <div className='audio-modal__container'> + <Audio + src={media.get('url')} + alt={media.get('description')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + poster={media.get('preview_url') || accountStaticAvatar} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} + autoPlay={options.autoPlay} + /> + </div> + + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/block_modal.js b/app/javascript/flavours/glitch/features/ui/components/block_modal.js new file mode 100644 index 000000000..a07baeaa6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/block_modal.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { makeGetAccount } from '../../../selectors'; +import Button from '../../../components/button'; +import { closeModal } from '../../../actions/modal'; +import { blockAccount } from '../../../actions/accounts'; +import { initReport } from '../../../actions/reports'; + + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => ({ + account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account) { + dispatch(blockAccount(account.get('id'))); + }, + + onBlockAndReport(account) { + dispatch(blockAccount(account.get('id'))); + dispatch(initReport(account)); + }, + + onClose() { + dispatch(closeModal()); + }, + }; +}; + +export default @connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +class BlockModal extends React.PureComponent { + + static propTypes = { + account: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + onBlockAndReport: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account); + } + + handleSecondary = () => { + this.props.onClose(); + this.props.onBlockAndReport(this.props.account); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + render () { + const { account } = this.props; + + return ( + <div className='modal-root__modal block-modal'> + <div className='block-modal__container'> + <p> + <FormattedMessage + id='confirmations.block.message' + defaultMessage='Are you sure you want to block {name}?' + values={{ name: <strong>@{account.get('acct')}</strong> }} + /> + </p> + </div> + + <div className='block-modal__action-bar'> + <Button onClick={this.handleCancel} className='block-modal__cancel-button'> + <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> + </Button> + <Button onClick={this.handleSecondary} className='confirmation-modal__secondary-button'> + <FormattedMessage id='confirmations.block.block_and_report' defaultMessage='Block & Report' /> + </Button> + <Button onClick={this.handleClick} ref={this.setRef}> + <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> + </Button> + </div> + </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..c4af25599 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js @@ -0,0 +1,151 @@ +import React from 'react'; +import { connect } from 'react-redux'; +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 AttachmentList from 'flavours/glitch/components/attachment_list'; +import Icon from 'flavours/glitch/components/icon'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown'; +import classNames from 'classnames'; +import { changeBoostPrivacy } from 'flavours/glitch/actions/boosts'; + +const messages = defineMessages({ + cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +const mapStateToProps = state => { + return { + privacy: state.getIn(['boosts', 'new', 'privacy']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onChangeBoostPrivacy(value) { + dispatch(changeBoostPrivacy(value)); + }, + }; +}; + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class BoostModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + missingMediaDescription: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleReblog = () => { + this.props.onReblog(this.props.status, this.props.privacy); + 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); + } + } + + _findContainer = () => { + return document.getElementsByClassName('modal-root__container')[0]; + }; + + setRef = (c) => { + this.button = c; + } + + render () { + const { status, missingMediaDescription, privacy, intl } = this.props; + const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog; + + const visibilityIconInfo = { + 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, + 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) }, + 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) }, + }; + + const visibilityIcon = visibilityIconInfo[status.get('visibility')]; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className={classNames('status', `status-${status.get('visibility')}`, '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 noreferrer'> + <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> + <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} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + </div> + + <div className='boost-modal__action-bar'> + <div> + { missingMediaDescription ? + <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' /> + : + <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /> + } + </div> + + {status.get('visibility') !== 'private' && !status.get('reblogged') && ( + <PrivacyDropdown + noDirect + value={privacy} + container={this._findContainer} + onChange={this.props.onChangeBoostPrivacy} + /> + )} + <Button text={intl.formatMessage(buttonText)} 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..528ff73a6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_header.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; + +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 = <Icon id={icon} fixedWidth className='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..d04b869b6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import Icon from 'flavours/glitch/components/icon'; + +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}> + <Icon id={icon} fixedWidth className='column-link__icon' /> + {text} + {badgeElement} + </a> + ); + } else if (to) { + return ( + <Link to={to} className='column-link'> + <Icon id={icon} fixedWidth className='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'> + <Icon id={icon} fixedWidth className='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..22c00c915 --- /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} placeholder /> + <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..d4e0bedac --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -0,0 +1,272 @@ +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 TabsBar, { 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, + Directory, +} from 'flavours/glitch/util/async-components'; +import Icon from 'flavours/glitch/components/icon'; +import ComposePanel from './compose_panel'; +import NavigationPanel from './navigation_panel'; + +import { supportsPassiveEvents } from 'detect-passive-events'; +import { scrollRight } from 'flavours/glitch/util/scroll'; + +const componentMap = { + 'COMPOSE': Compose, + 'HOME': HomeTimeline, + 'NOTIFICATIONS': Notifications, + 'PUBLIC': PublicTimeline, + 'REMOTE': PublicTimeline, + 'COMMUNITY': CommunityTimeline, + 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, + 'FAVOURITES': FavouritedStatuses, + 'BOOKMARKS': BookmarkedStatuses, + 'LIST': ListTimeline, + 'DIRECTORY': Directory, +}; + +const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started|^\/start/); + +const messages = defineMessages({ + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, +}); + +export default @(component => injectIntl(component, { withRef: true })) +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, + navbarUnder: PropTypes.bool, + openSettings: PropTypes.func, + }; + + // Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS + mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)'); + + state = { + shouldAnimate: false, + renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches), + } + + componentWillReceiveProps() { + if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) { + this.setState({ shouldAnimate: false }); + } + } + + componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); + } + + if (this.mediaQuery) { + if (this.mediaQuery.addEventListener) { + this.mediaQuery.addEventListener('change', this.handleLayoutChange); + } else { + this.mediaQuery.addListener(this.handleLayoutChange); + } + this.setState({ renderComposePanel: !this.mediaQuery.matches }); + } + + 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, supportsPassiveEvents ? { passive: true } : false); + } + + const newIndex = getIndex(this.context.router.history.location.pathname); + + if (this.lastIndex !== newIndex) { + this.lastIndex = newIndex; + this.setState({ shouldAnimate: true }); + } + } + + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + + if (this.mediaQuery) { + if (this.mediaQuery.removeEventListener) { + this.mediaQuery.removeEventListener('change', this.handleLayoutChange); + } else { + this.mediaQuery.removeListener(this.handleLayouteChange); + } + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + const modifier = this.isRtlLayout ? -1 : 1; + this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier); + } + } + + handleLayoutChange = (e) => { + this.setState({ renderComposePanel: !e.matches }); + } + + 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'); + + if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } + } + + 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 columns-area--mobile' key={index}> + {view} + </div> + ); + } + + renderLoading = columnId => () => { + return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />; + } + + renderError = (props) => { + return <BundleColumnError {...props} />; + } + + render () { + const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props; + const { shouldAnimate, renderComposePanel } = this.state; + + const columnIndex = getIndex(this.context.router.history.location.pathname); + + 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)}><Icon id='pencil' /></Link>; + + const content = columnIndex !== -1 ? ( + <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} 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> + ) : ( + <div key='content' className='columns-area columns-area--mobile'>{children}</div> + ); + + return ( + <div className='columns-area__panels'> + <div className='columns-area__panels__pane columns-area__panels__pane--compositional'> + <div className='columns-area__panels__pane__inner'> + {renderComposePanel && <ComposePanel />} + </div> + </div> + + <div className='columns-area__panels__main'> + {!navbarUnder && <TabsBar key='tabs' />} + {content} + {navbarUnder && <TabsBar key='tabs' />} + </div> + + <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'> + <div className='columns-area__panels__pane__inner'> + <NavigationPanel onOpenSettings={openSettings} /> + </div> + </div> + + {floatingActionButton} + </div> + ); + } + + 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/compose_panel.js b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js new file mode 100644 index 000000000..498f09ab6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js @@ -0,0 +1,16 @@ +import React from 'react'; +import SearchContainer from 'flavours/glitch/features/compose/containers/search_container'; +import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; +import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container'; +import LinkFooter from './link_footer'; + +const ComposePanel = () => ( + <div className='compose-panel'> + <SearchContainer openInRoute /> + <NavigationContainer /> + <ComposeFormContainer singleColumn /> + <LinkFooter withHotkeys /> + </div> +); + +export default ComposePanel; 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..47a49c0c7 --- /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'; + +export default @injectIntl +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..0d10204fc --- /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 + */ +export default @connect(mapStateToProps, mapDispatchToProps) +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..b6f5e628d --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import api from 'flavours/glitch/util/api'; +import IconButton from 'flavours/glitch/components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @injectIntl +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 { intl, onClose } = this.props; + const { oembed } = this.state; + + return ( + <div className='modal-root__modal report-modal embed-modal'> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='status.embed' defaultMessage='Embed' /> + </div> + + <div className='report-modal__container embed-modal__container' style={{ display: 'block' }}> + <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..ea1d7876e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js @@ -0,0 +1,113 @@ +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 AttachmentList from 'flavours/glitch/components/attachment_list'; +import Icon from 'flavours/glitch/components/icon'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; + +const messages = defineMessages({ + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +export default @injectIntl +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; + + const visibilityIconInfo = { + 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, + 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) }, + 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) }, + }; + + const visibilityIcon = visibilityIconInfo[status.get('visibility')]; + + return ( + <div className='modal-root__modal favourite-modal'> + <div className='favourite-modal__container'> + <div className={classNames('status', `status-${status.get('visibility')}`, '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 noreferrer'> + <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> + <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} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </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 + <Icon id='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..893e76585 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -0,0 +1,418 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; +import { changeUploadCompose, uploadThumbnail } from 'flavours/glitch/actions/compose'; +import { getPointerPosition } from 'flavours/glitch/features/video'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Button from 'flavours/glitch/components/button'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import Textarea from 'react-textarea-autosize'; +import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; +import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; +import { length } from 'stringz'; +import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components'; +import GIFV from 'flavours/glitch/components/gifv'; +import { me } from 'flavours/glitch/util/initial_state'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; +// eslint-disable-next-line import/extensions +import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'flavours/glitch/util/config'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, + placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, + chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' }, +}); + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), + account: state.getIn(['accounts', me]), + isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onSave: (description, x, y) => { + dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + }, + + onSelectThumbnail: files => { + dispatch(uploadThumbnail(id, files[0])); + }, + +}); + +const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') + .replace(/\n/g, ' ') + .replace(/\*\*\*\*\*\*/g, '\n\n'); + +class ImageLoader extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + }; + + state = { + loading: true, + }; + + componentDidMount() { + const image = new Image(); + image.addEventListener('load', () => this.setState({ loading: false })); + image.src = this.props.src; + } + + render () { + const { loading } = this.state; + + if (loading) { + return <canvas width={this.props.width} height={this.props.height} />; + } else { + return <img {...this.props} alt='' />; + } + } + +} + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class FocalPointModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map.isRequired, + isUploadingThumbnail: PropTypes.bool, + onSave: PropTypes.func.isRequired, + onSelectThumbnail: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + x: 0, + y: 0, + focusX: 0, + focusY: 0, + dragging: false, + description: '', + dirty: false, + progress: 0, + loading: true, + ocrStatus: '', + }; + + 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 }); + } + + handleTouchStart = e => { + document.addEventListener('touchmove', this.handleMouseMove); + document.addEventListener('touchend', this.handleTouchEnd); + + 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 }); + } + + handleTouchEnd = () => { + document.removeEventListener('touchmove', this.handleMouseMove); + document.removeEventListener('touchend', this.handleTouchEnd); + + this.setState({ dragging: false }); + } + + 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, dirty: true }); + } + + updatePositionFromMedia = media => { + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const description = media.get('description') || ''; + + if (focusX && focusY) { + const x = (focusX / 2) + .5; + const y = (focusY / -2) + .5; + + this.setState({ + x, + y, + focusX, + focusY, + description, + dirty: false, + }); + } else { + this.setState({ + x: 0.5, + y: 0.5, + focusX: 0, + focusY: 0, + description, + dirty: false, + }); + } + } + + handleChange = e => { + this.setState({ description: e.target.value, dirty: true }); + } + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ description: e.target.value, dirty: true }); + this.handleSubmit(); + } + } + + handleSubmit = () => { + this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); + this.props.onClose(); + } + + setRef = c => { + this.node = c; + } + + handleTextDetection = () => { + const { media } = this.props; + + this.setState({ detecting: true }); + + fetchTesseract().then(({ createWorker }) => { + const worker = createWorker({ + workerPath: tesseractWorkerPath, + corePath: tesseractCorePath, + langPath: `${assetHost}/ocr/lang-data/`, + logger: ({ status, progress }) => { + if (status === 'recognizing text') { + this.setState({ ocrStatus: 'detecting', progress }); + } else { + this.setState({ ocrStatus: 'preparing', progress }); + } + }, + }); + + let media_url = media.get('url'); + + if (window.URL && URL.createObjectURL) { + try { + media_url = URL.createObjectURL(media.get('file')); + } catch (error) { + console.error(error); + } + } + + (async () => { + await worker.load(); + await worker.loadLanguage('eng'); + await worker.initialize('eng'); + const { data: { text } } = await worker.recognize(media_url); + this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }); + await worker.terminate(); + })(); + }).catch((e) => { + console.error(e); + this.setState({ detecting: false }); + }); + } + + handleThumbnailChange = e => { + if (e.target.files.length > 0) { + this.setState({ dirty: true }); + this.props.onSelectThumbnail(e.target.files); + } + } + + setFileInputRef = c => { + this.fileInput = c; + } + + handleFileInputClick = () => { + this.fileInput.click(); + } + + render () { + const { media, intl, account, onClose, isUploadingThumbnail } = this.props; + const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state; + + const width = media.getIn(['meta', 'original', 'width']) || null; + const height = media.getIn(['meta', 'original', 'height']) || null; + const focals = ['image', 'gifv'].includes(media.get('type')); + const thumbnailable = ['audio', 'video'].includes(media.get('type')); + + const previewRatio = 16/9; + const previewWidth = 200; + const previewHeight = previewWidth / previewRatio; + + let descriptionLabel = null; + + if (media.get('type') === 'audio') { + descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />; + } else if (media.get('type') === 'video') { + descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />; + } else { + descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />; + } + + let ocrMessage = ''; + if (ocrStatus === 'detecting') { + ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />; + } else { + ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />; + } + + return ( + <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> + <div className='report-modal__target'> + <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__comment'> + {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} + + {thumbnailable && ( + <React.Fragment> + <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label> + + <Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} /> + + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span> + + <input + id='upload-modal__thumbnail' + ref={this.setFileInputRef} + type='file' + accept='image/png,image/jpeg' + onChange={this.handleThumbnailChange} + style={{ display: 'none' }} + disabled={isUploadingThumbnail} + /> + </label> + + <hr className='setting-divider' /> + </React.Fragment> + )} + + <label className='setting-text-label' htmlFor='upload-modal__description'> + {descriptionLabel} + </label> + + <div className='setting-text__wrapper'> + <Textarea + id='upload-modal__description' + className='setting-text light' + value={detecting ? '…' : description} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + disabled={detecting} + autoFocus + /> + + <div className='setting-text__modifiers'> + <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} /> + </div> + </div> + + <div className='setting-text__toolbar'> + <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> + <CharacterCounter max={1500} text={detecting ? '' : description} /> + </div> + + <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} /> + </div> + + <div className='focal-point-modal__content'> + {focals && ( + <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> + {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} + {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} + + <div className='focal-point__preview'> + <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> + <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> + </div> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' /> + </div> + )} + + {media.get('type') === 'video' && ( + <Video + preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} + blurhash={media.get('blurhash')} + src={media.get('url')} + detailed + inline + editable + /> + )} + + {media.get('type') === 'audio' && ( + <Audio + src={media.get('url')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + poster={media.get('preview_url') || account.get('avatar_static')} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} + editable + /> + )} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js new file mode 100644 index 000000000..c30427896 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; +import { connect } from 'react-redux'; +import { NavLink, withRouter } from 'react-router-dom'; +import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; +import { List as ImmutableList } from 'immutable'; +import { FormattedMessage } from 'react-intl'; + +const mapStateToProps = state => ({ + count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, +}); + +export default @withRouter +@connect(mapStateToProps) +class FollowRequestsNavLink extends React.Component { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + count: PropTypes.number.isRequired, + }; + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(fetchFollowRequests()); + } + + render () { + const { count } = this.props; + + if (count === 0) { + return null; + } + + return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>; + } + +} 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..c6f16a792 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js @@ -0,0 +1,164 @@ +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, + zoomButtonHidden: PropTypes.bool, + } + + 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} + width={width} + height={height} + zoomButtonHidden={this.props.zoomButtonHidden} + /> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js new file mode 100644 index 000000000..4d7fc36c2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -0,0 +1,71 @@ +import { connect } from 'react-redux'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state'; +import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links'; +import { logOut } from 'flavours/glitch/util/log_out'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default @injectIntl +@connect(null, mapDispatchToProps) +class LinkFooter extends React.PureComponent { + + static propTypes = { + onLogout: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + } + + render () { + return ( + <div className='getting-started__footer'> + <ul> + {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} + {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></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='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li> + <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li> + <li><a href={signOutLink} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></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 noreferrer' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>, + Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener noreferrer' target='_blank'>Mastodon</a> }} + /> + </p> + </div> + ); + } + +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js new file mode 100644 index 000000000..354e35027 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { fetchLists } from 'flavours/glitch/actions/lists'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { NavLink, withRouter } from 'react-router-dom'; +import Icon from 'flavours/glitch/components/icon'; + +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'))).take(4); +}); + +const mapStateToProps = state => ({ + lists: getOrderedLists(state), +}); + +export default @withRouter +@connect(mapStateToProps) +class ListPanel extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchLists()); + } + + render () { + const { lists } = this.props; + + if (!lists || lists.isEmpty()) { + return null; + } + + return ( + <div> + <hr /> + + {lists.map(list => ( + <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink> + ))} + </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..a8cbb837e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -0,0 +1,259 @@ +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 classNames from 'classnames'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImageLoader from './image_loader'; +import Icon from 'flavours/glitch/components/icon'; +import GIFV from 'flavours/glitch/components/gifv'; +import Footer from 'flavours/glitch/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'flavours/glitch/blurhash'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +export default @injectIntl +class MediaModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + statusId: PropTypes.string, + index: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + currentTime: PropTypes.number, + autoPlay: PropTypes.bool, + volume: PropTypes.number, + }; + + state = { + index: null, + navigationHidden: false, + zoomButtonHidden: false, + }; + + handleSwipe = (index) => { + this.setState({ index: index % this.props.media.size }); + } + + handleTransitionEnd = () => { + this.setState({ + zoomButtonHidden: false, + }); + } + + handleNextClick = () => { + this.setState({ + index: (this.getIndex() + 1) % this.props.media.size, + zoomButtonHidden: true, + }); + } + + handlePrevClick = () => { + this.setState({ + index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size, + zoomButtonHidden: true, + }); + } + + handleChangeIndex = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + + this.setState({ + index: index % this.props.media.size, + zoomButtonHidden: true, + }); + } + + 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); + this._sendBackgroundColor(); + } + + componentWillUnmount () { + window.removeEventListener('keydown', this.handleKeyDown); + this.props.onChangeBackgroundColor(null); + } + + 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.statusId}`); + } + + this._sendBackgroundColor(); + } + + componentDidUpdate (prevProps, prevState) { + if (prevState.index !== this.state.index) { + this._sendBackgroundColor(); + } + } + + _sendBackgroundColor () { + const { media, onChangeBackgroundColor } = this.props; + const index = this.getIndex(); + const blurhash = media.getIn([index, 'blurhash']); + + if (blurhash) { + const backgroundColor = getAverageFromBlurhash(blurhash); + onChangeBackgroundColor(backgroundColor); + } + } + + render () { + const { media, statusId, intl, onClose } = this.props; + const { navigationHidden } = this.state; + + const index = this.getIndex(); + + 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)}><Icon id='chevron-left' fixedWidth /></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)}><Icon id='chevron-right' fixedWidth /></button>; + + 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} + zoomButtonHidden={this.state.zoomButtonHidden} + /> + ); + } else if (image.get('type') === 'video') { + const { currentTime, autoPlay, volume } = 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')} + frameRate={image.getIn(['meta', 'original', 'frame_rate'])} + currentTime={currentTime || 0} + autoPlay={autoPlay || false} + volume={volume || 1} + onCloseVideo={onClose} + detailed + alt={image.get('description')} + key={image.get('url')} + /> + ); + } else if (image.get('type') === 'gifv') { + return ( + <GIFV + src={image.get('url')} + 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, + }); + + let pagination; + + if (media.size > 1) { + pagination = media.map((item, i) => ( + <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}> + {i + 1} + </button> + )); + } + + 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} + onTransitionEnd={this.handleTransitionEnd} + 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} + + <div className='media-modal__overlay'> + {pagination && <ul className='media-modal__pagination'>{pagination}</ul>} + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> + </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..0fd70de34 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar'; +import Base from 'flavours/glitch/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 AudioModal from './audio_modal'; +import DoodleModal from './doodle_modal'; +import ConfirmationModal from './confirmation_modal'; +import FocalPointModal from './focal_point_modal'; +import { + OnboardingModal, + MuteModal, + BlockModal, + 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 }), + 'AUDIO': () => Promise.resolve({ default: AudioModal }), + 'BOOST': () => Promise.resolve({ default: BoostModal }), + 'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }), + 'DOODLE': () => Promise.resolve({ default: DoodleModal }), + 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, + 'BLOCK': BlockModal, + '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, + }; + + state = { + backgroundColor: null, + }; + + getSnapshotBeforeUpdate () { + return { visible: !!this.props.type }; + } + + componentDidUpdate (prevProps, prevState, { visible }) { + if (visible) { + document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + } else { + document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; + } + } + + setBackgroundColor = color => { + this.setState({ backgroundColor: color }); + } + + 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 { backgroundColor } = this.state; + const visible = !!type; + + return ( + <Base backgroundColor={backgroundColor} 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} onChangeBackgroundColor={this.setBackgroundColor} 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..7d25db316 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { defineMessages, 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, changeMuteDuration } from 'flavours/glitch/actions/mutes'; + +const messages = defineMessages({ + 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}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); + +const mapStateToProps = state => { + return { + account: state.getIn(['mutes', 'new', 'account']), + notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); + }, + + onClose() { + dispatch(closeModal()); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, + }; +}; + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class MuteModal extends React.PureComponent { + + static propTypes = { + account: PropTypes.object.isRequired, + notifications: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onToggleNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + toggleNotifications = () => { + this.props.onToggleNotifications(); + } + + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + + render () { + const { account, notifications, muteDuration, intl } = 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> + <p className='mute-modal__explanation'> + <FormattedMessage + id='confirmations.mute.explanation' + defaultMessage='This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.' + /> + </p> + <div className='setting-toggle'> + <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} /> + <label className='setting-toggle__label' htmlFor='mute-modal__hide-notifications-checkbox'> + <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> + </label> + </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <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> + + <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/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js new file mode 100644 index 000000000..50e7d5c48 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { NavLink, withRouter } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; +import { profile_directory, showTrends } from 'flavours/glitch/util/initial_state'; +import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links'; +import NotificationsCounterIcon from './notifications_counter_icon'; +import FollowRequestsNavLink from './follow_requests_nav_link'; +import ListPanel from './list_panel'; +import TrendsContainer from 'flavours/glitch/features/getting_started/containers/trends_container'; + +const NavigationPanel = ({ onOpenSettings }) => ( + <div className='navigation-panel'> + <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink> + <FollowRequestsNavLink /> + <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink> + <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink> + {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>} + <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> + + <ListPanel /> + + <hr /> + + {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>} + <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a> + {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>} + + {showTrends && <div className='flex-spacer' />} + {showTrends && <TrendsContainer />} + </div> +); + +export default withRouter(NavigationPanel); diff --git a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js new file mode 100644 index 000000000..6b52ef9b4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; + +const mapStateToProps = state => ({ + count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0, + id: 'bell', +}); + +export default connect(mapStateToProps)(IconWithBadge); 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..935c26be6 --- /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']), +}); + +export default @connect(mapStateToProps) +@injectIntl +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..5cb7c5d07 --- /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; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +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='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> + <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..a67405215 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js @@ -0,0 +1,86 @@ +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 Icon from 'flavours/glitch/components/icon'; +import NotificationsCounterIcon from './notifications_counter_icon'; + +export const links = [ + <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, + <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, + <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, + <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, + <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, + <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>, +]; + +export function getIndex (path) { + return links.findIndex(link => link.props.to === path); +} + +export function getLink (index) { + return links[index].props.to; +} + +export default @injectIntl +@withRouter +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 ( + <div className='tabs-bar__wrapper'> + <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> + + <div id='tabs-bar__portal' /> + </div> + ); + } + +} 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..6b6e615a6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -0,0 +1,65 @@ +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 Footer from 'flavours/glitch/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'flavours/glitch/blurhash'; + +export default class VideoModal extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + statusId: PropTypes.string, + options: PropTypes.shape({ + startTime: PropTypes.number, + autoPlay: PropTypes.bool, + defaultVolume: PropTypes.number, + }), + onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + }; + + componentDidMount () { + const { media, onChangeBackgroundColor, onClose } = this.props; + + const backgroundColor = getAverageFromBlurhash(media.get('blurhash')); + + if (backgroundColor) { + onChangeBackgroundColor(backgroundColor); + } + } + + render () { + const { media, statusId, onClose } = this.props; + const options = this.props.options || {}; + + return ( + <div className='modal-root__modal video-modal'> + <div className='video-modal__container'> + <Video + preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} + blurhash={media.get('blurhash')} + src={media.get('url')} + currentTime={options.startTime} + autoPlay={options.autoPlay} + volume={options.defaultVolume} + onCloseVideo={onClose} + detailed + alt={media.get('description')} + /> + </div> + + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </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..caeeced64 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js @@ -0,0 +1,450 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' }, + expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' }, +}); + +const MIN_SCALE = 1; +const MAX_SCALE = 4; +const NAV_BAR_HEIGHT = 66; + +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)); + +// Normalizing mousewheel speed across browsers +// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js +const normalizeWheel = event => { + // Reasonable defaults + const PIXEL_STEP = 10; + const LINE_HEIGHT = 40; + const PAGE_HEIGHT = 800; + + let sX = 0, + sY = 0, // spinX, spinY + pX = 0, + pY = 0; // pixelX, pixelY + + // Legacy + if ('detail' in event) { + sY = event.detail; + } + if ('wheelDelta' in event) { + sY = -event.wheelDelta / 120; + } + if ('wheelDeltaY' in event) { + sY = -event.wheelDeltaY / 120; + } + if ('wheelDeltaX' in event) { + sX = -event.wheelDeltaX / 120; + } + + // side scrolling on FF with DOMMouseScroll + if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) { + sX = sY; + sY = 0; + } + + pX = sX * PIXEL_STEP; + pY = sY * PIXEL_STEP; + + if ('deltaY' in event) { + pY = event.deltaY; + } + if ('deltaX' in event) { + pX = event.deltaX; + } + + if ((pX || pY) && event.deltaMode) { + if (event.deltaMode === 1) { // delta in LINE units + pX *= LINE_HEIGHT; + pY *= LINE_HEIGHT; + } else { // delta in PAGE units + pX *= PAGE_HEIGHT; + pY *= PAGE_HEIGHT; + } + } + + // Fall-back if spin cannot be determined + if (pX && !sX) { + sX = (pX < 1) ? -1 : 1; + } + if (pY && !sY) { + sY = (pY < 1) ? -1 : 1; + } + + return { + spinX: sX, + spinY: sY, + pixelX: pX, + pixelY: pY, + }; +}; + +export default @injectIntl +class ZoomableImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + zoomButtonHidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + scale: MIN_SCALE, + zoomMatrix: { + type: null, // 'width' 'height' + fullScreen: null, // bool + rate: null, // full screen scale rate + clientWidth: null, + clientHeight: null, + offsetWidth: null, + offsetHeight: null, + clientHeightFixed: null, + scrollTop: null, + scrollLeft: null, + translateX: null, + translateY: null, + }, + zoomState: 'expand', // 'expand' 'compress' + navigationHidden: false, + dragPosition: { top: 0, left: 0, x: 0, y: 0 }, + dragged: false, + lockScroll: { x: 0, y: 0 }, + lockTranslate: { x: 0, y: 0 }, + } + + 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)); + + handler = this.mouseDownHandler; + this.container.addEventListener('mousedown', handler); + this.removers.push(() => this.container.removeEventListener('mousedown', handler)); + + handler = this.mouseWheelHandler; + this.container.addEventListener('wheel', handler); + this.removers.push(() => this.container.removeEventListener('wheel', handler)); + // Old Chrome + this.container.addEventListener('mousewheel', handler); + this.removers.push(() => this.container.removeEventListener('mousewheel', handler)); + // Old Firefox + this.container.addEventListener('DOMMouseScroll', handler); + this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler)); + + this.initZoomMatrix(); + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + componentDidUpdate () { + this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' }); + + if (this.state.scale === MIN_SCALE) { + this.container.style.removeProperty('cursor'); + } + } + + UNSAFE_componentWillReceiveProps () { + // reset when slide to next image + if (this.props.zoomButtonHidden) { + this.setState({ + scale: MIN_SCALE, + lockTranslate: { x: 0, y: 0 }, + }, () => { + this.container.scrollLeft = 0; + this.container.scrollTop = 0; + }); + } + } + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + mouseWheelHandler = e => { + e.preventDefault(); + + const event = normalizeWheel(e); + + if (this.state.zoomMatrix.type === 'width') { + // full width, scroll vertical + this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y); + } else { + // full height, scroll horizontal + this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x); + } + + // lock horizontal scroll + this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x); + } + + mouseDownHandler = e => { + this.container.style.cursor = 'grabbing'; + this.container.style.userSelect = 'none'; + + this.setState({ dragPosition: { + left: this.container.scrollLeft, + top: this.container.scrollTop, + // Get the current mouse position + x: e.clientX, + y: e.clientY, + } }); + + this.image.addEventListener('mousemove', this.mouseMoveHandler); + this.image.addEventListener('mouseup', this.mouseUpHandler); + } + + mouseMoveHandler = e => { + const dx = e.clientX - this.state.dragPosition.x; + const dy = e.clientY - this.state.dragPosition.y; + + this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x); + this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y); + + this.setState({ dragged: true }); + } + + mouseUpHandler = () => { + this.container.style.cursor = 'grab'; + this.container.style.removeProperty('user-select'); + + this.image.removeEventListener('mousemove', this.mouseMoveHandler); + this.image.removeEventListener('mouseup', this.mouseUpHandler); + } + + 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 _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate); + 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, zoomMatrix } = 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; + // reset the translateX/Y constantly + if (nextScale < zoomMatrix.rate) { + this.setState({ + lockTranslate: { + x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)), + y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)), + }, + }); + } + }); + } + + handleClick = e => { + // don't propagate event to MediaModal + e.stopPropagation(); + const dragged = this.state.dragged; + this.setState({ dragged: false }); + if (dragged) return; + const handler = this.props.onClick; + if (handler) handler(); + this.setState({ navigationHidden: !this.state.navigationHidden }); + } + + handleMouseDown = e => { + e.preventDefault(); + } + + initZoomMatrix = () => { + const { width, height } = this.props; + const { clientWidth, clientHeight } = this.container; + const { offsetWidth, offsetHeight } = this.image; + const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT; + + const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height'; + const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed; + const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight; + const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2; + const scrollLeft = (clientWidth - offsetWidth) / 2; + const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0; + const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0; + + this.setState({ + zoomMatrix: { + type: type, + fullScreen: fullScreen, + rate: rate, + clientWidth: clientWidth, + clientHeight: clientHeight, + offsetWidth: offsetWidth, + offsetHeight: offsetHeight, + clientHeightFixed: clientHeightFixed, + scrollTop: scrollTop, + scrollLeft: scrollLeft, + translateX: translateX, + translateY: translateY, + }, + }); + } + + handleZoomClick = e => { + e.preventDefault(); + e.stopPropagation(); + + const { scale, zoomMatrix } = this.state; + + if ( scale >= zoomMatrix.rate ) { + this.setState({ + scale: MIN_SCALE, + lockScroll: { + x: 0, + y: 0, + }, + lockTranslate: { + x: 0, + y: 0, + }, + }, () => { + this.container.scrollLeft = 0; + this.container.scrollTop = 0; + }); + } else { + this.setState({ + scale: zoomMatrix.rate, + lockScroll: { + x: zoomMatrix.scrollLeft, + y: zoomMatrix.scrollTop, + }, + lockTranslate: { + x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX, + y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY, + }, + }, () => { + this.container.scrollLeft = zoomMatrix.scrollLeft; + this.container.scrollTop = zoomMatrix.scrollTop; + }); + } + + this.container.style.cursor = 'grab'; + this.container.style.removeProperty('user-select'); + } + + setContainerRef = c => { + this.container = c; + } + + setImageRef = c => { + this.image = c; + } + + render () { + const { alt, src, width, height, intl } = this.props; + const { scale, lockTranslate } = this.state; + const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll'; + const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : ''; + const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand); + + return ( + <React.Fragment> + <IconButton + className={`media-modal__zoom-button ${zoomButtonShouldHide}`} + title={zoomButtonTitle} + icon={this.state.zoomState} + onClick={this.handleZoomClick} + size={40} + style={{ + fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */ + }} + /> + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + title={alt} + src={src} + width={width} + height={height} + style={{ + transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`, + transformOrigin: '0 0', + }} + draggable={false} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + /> + </div> + </React.Fragment> + ); + } + +} 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..b69842cd6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import ColumnsArea from '../components/columns_area'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), + swipeToChangeColumns: state.getIn(['local_settings', 'swipe_to_change_columns']), +}); + +const mapDispatchToProps = dispatch => ({ + openSettings (e) { + e.preventDefault(); + e.stopPropagation(); + dispatch(openModal('SETTINGS', {})); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps, 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..82278a3be --- /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, notification[`${key}_values`]); + } + })); + + 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..bd2d2eb4e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js @@ -0,0 +1,88 @@ +import { connect } from 'react-redux'; +import StatusList from 'flavours/glitch/components/status_list'; +import { scrollTopTimeline, loadPending } 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 getRegex = createSelector([ + (state, { type }) => state.getIn(['settings', type, 'regex', 'body']), +], (rawRegex) => { + let regex = null; + + try { + regex = rawRegex && new RegExp(rawRegex.trim(), 'i'); + } catch (e) { + // Bad regex, don't affect filters + } + return regex; +}); + +const makeGetStatusIds = (pending = false) => createSelector([ + (state, { type }) => state.getIn(['settings', type], ImmutableMap()), + (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), + (state) => state.get('statuses'), + getRegex, +], (columnSettings, statusIds, statuses, regex) => { + const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); + + return statusIds.filter(id => { + if (id === null) return true; + + const statusForId = statuses.get(id); + let showStatus = true; + + if (statusForId.get('account') === me) return 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) { + 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 getPendingStatusIds = makeGetStatusIds(true); + + 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']), + numPending: getPendingStatusIds(state, { type: timelineId }).size, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { timelineId }) => ({ + + onScrollToTop: debounce(() => { + dispatch(scrollTopTimeline(timelineId, true)); + }, 100), + + onScroll: debounce(() => { + dispatch(scrollTopTimeline(timelineId, false)); + }, 100), + + onLoadPending: () => dispatch(loadPending(timelineId)), + +}); + +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..1149eb14e --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -0,0 +1,639 @@ +import React from 'react'; +import NotificationsContainer from './containers/notifications_container'; +import PropTypes from 'prop-types'; +import LoadingBarContainer from './containers/loading_bar_container'; +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, changeComposeSpoilerness } 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 { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; +import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers'; +import UploadArea from './components/upload_area'; +import PermaLink from 'flavours/glitch/components/permalink'; +import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; +import Favico from 'favico.js'; +import PictureInPicture from 'flavours/glitch/features/picture_in_picture'; +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, + Search, + GettingStartedMisc, + Directory, + FollowRecommendations, +} from 'flavours/glitch/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import { me } from 'flavours/glitch/util/initial_state'; +import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; +import { defineMessages, FormattedMessage, 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, + canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, + 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']), + moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]), + firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, +}); + +const keyMap = { + help: '?', + new: 'n', + search: 's', + forceNew: 'option+n', + toggleComposeSpoilers: 'option+x', + 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', + toggleSensitive: 'h', + openMedia: 'e', +}; + +class SwitchingColumnsArea extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + layout: PropTypes.string, + location: PropTypes.object, + navbarUnder: PropTypes.bool, + onLayoutChange: PropTypes.func.isRequired, + }; + + state = { + mobile: isMobile(window.innerWidth, this.props.layout), + }; + + componentWillReceiveProps (nextProps) { + if (nextProps.layout !== this.props.layout) { + this.setState({ mobile: isMobile(window.innerWidth, nextProps.layout) }); + } + } + + componentWillMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + + if (this.state.mobile) { + document.body.classList.toggle('layout-single-column', true); + document.body.classList.toggle('layout-multiple-columns', false); + } else { + document.body.classList.toggle('layout-single-column', false); + document.body.classList.toggle('layout-multiple-columns', true); + } + } + + componentDidUpdate (prevProps, prevState) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.node.handleChildrenContentChange(); + } + + if (prevState.mobile !== this.state.mobile) { + document.body.classList.toggle('layout-single-column', this.state.mobile); + document.body.classList.toggle('layout-multiple-columns', !this.state.mobile); + } + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + handleLayoutChange = debounce(() => { + // The cached heights are no longer accurate, invalidate + this.props.onLayoutChange(); + }, 500, { + trailing: true, + }) + + handleResize = () => { + const mobile = isMobile(window.innerWidth, this.props.layout); + + if (mobile !== this.state.mobile) { + this.handleLayoutChange.cancel(); + this.props.onLayoutChange(); + this.setState({ mobile }); + } else { + this.handleLayoutChange(); + } + } + + setRef = c => { + if (c) { + this.node = c.getWrappedInstance(); + } + } + + render () { + const { children, navbarUnder } = this.props; + const singleColumn = this.state.mobile; + const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; + + return ( + <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}> + <WrappedSwitch> + {redirect} + <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='/start' component={FollowRecommendations} content={children} /> + <WrappedRoute path='/search' component={Search} content={children} /> + <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> + + <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> + ); + }; + +} + +export default @connect(mapStateToProps) +@injectIntl +@withRouter +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, + canUploadMore: 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, + moved: PropTypes.map, + firstLaunch: PropTypes.bool, + }; + + state = { + draggingOver: false, + }; + + handleBeforeUnload = (e) => { + const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props; + + dispatch(synchronouslySubmitMarkers()); + + 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); + } + } + + handleLayoutChange = () => { + // The cached heights are no longer accurate, invalidate + this.props.dispatch(clearHeight()); + } + + 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.props.canUploadMore) { + 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.canUploadMore) { + 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).filter((type) => type === 'text/plain').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)); + if (visibility) { + this.props.dispatch(submitMarkers({ immediate: true })); + } + } + + componentWillMount () { + window.addEventListener('beforeunload', this.handleBeforeUnload, false); + 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" }); + + // On first launch, redirect to the follow recommendations page + if (this.props.firstLaunch) { + this.context.router.history.replace('/start'); + this.props.dispatch(closeOnboarding()); + } + + this.props.dispatch(fetchMarkers()); + 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); + }; + + 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(); + } + } + + componentDidUpdate (prevProps) { + if (this.props.unreadNotifications != prevProps.unreadNotifications || + this.props.showFaviconBadge != prevProps.showFaviconBadge) { + if (this.favicon) { + try { + this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + } catch (err) { + console.error(err); + } + } + } + } + + componentWillUnmount () { + if (this.visibilityChange !== undefined) { + document.removeEventListener(this.visibilityChange, this.handleVisibilityChange); + } + + window.removeEventListener('beforeunload', this.handleBeforeUnload); + 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; + } + + handleHotkeyNew = e => { + e.preventDefault(); + + const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea'); + + if (element) { + element.focus(); + } + } + + handleHotkeySearch = e => { + e.preventDefault(); + + const element = this.node.querySelector('.search__input'); + + if (element) { + element.focus(); + } + } + + handleHotkeyForceNew = e => { + this.handleHotkeyNew(e); + this.props.dispatch(resetCompose()); + } + + handleHotkeyToggleComposeSpoilers = e => { + e.preventDefault(); + this.props.dispatch(changeComposeSpoilerness()); + } + + 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 { draggingOver } = this.state; + const { children, layout, isWide, navbarUnder, location, dropdownMenuIsOpen, moved } = 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, + toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers, + 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 }}> + {moved && (<div className='flash-message alert'> + <FormattedMessage + id='moved_to_warning' + defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.' + values={{ moved_to_link: ( + <PermaLink href={moved.get('url')} to={`/accounts/${moved.get('id')}`}> + @{moved.get('acct')} + </PermaLink> + )}} + /> + </div>)} + <SwitchingColumnsArea location={location} layout={layout} navbarUnder={navbarUnder} onLayoutChange={this.handleLayoutChange}> + {children} + </SwitchingColumnsArea> + + <PictureInPicture /> + <NotificationsContainer /> + <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..fcbf07ce2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -0,0 +1,672 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { is } from 'immutable'; +import { throttle, debounce } from 'lodash'; +import classNames from 'classnames'; +import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; +import { displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; +import Icon from 'flavours/glitch/components/icon'; +import Blurhash from 'flavours/glitch/components/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' }, +}); + +export 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; +}; + +export const fileNameFromURL = str => { + const url = new URL(str); + const pathname = url.pathname; + const index = pathname.lastIndexOf('/'); + + return pathname.substring(index + 1); +}; + +export default @injectIntl +class Video extends React.PureComponent { + + static propTypes = { + preview: PropTypes.string, + frameRate: PropTypes.string, + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + sensitive: PropTypes.bool, + currentTime: PropTypes.number, + onOpenVideo: PropTypes.func, + onCloseVideo: PropTypes.func, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + detailed: PropTypes.bool, + inline: PropTypes.bool, + editable: PropTypes.bool, + alwaysVisible: PropTypes.bool, + cacheWidth: PropTypes.func, + intl: PropTypes.object.isRequired, + visible: PropTypes.bool, + onToggleVisibility: PropTypes.func, + deployPictureInPicture: PropTypes.func, + preventPlayback: PropTypes.bool, + blurhash: PropTypes.string, + autoPlay: PropTypes.bool, + volume: PropTypes.number, + muted: PropTypes.bool, + componetIndex: PropTypes.number, + }; + + static defaultProps = { + frameRate: '25', + }; + + 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.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), + }; + + componentWillReceiveProps (nextProps) { + if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ revealed: nextProps.visible }); + } + } + + setPlayerRef = c => { + this.player = c; + + if (this.player) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.player.offsetWidth; + + if (width && width != this.state.containerWidth) { + if (this.props.cacheWidth) { + this.props.cacheWidth(width); + } + + this.setState({ + containerWidth: width, + }); + } + } + + 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; + } + + handleClickRoot = e => e.stopPropagation(); + + handlePlay = () => { + this.setState({ paused: false }); + this._updateTime(); + } + + handlePause = () => { + this.setState({ paused: true }); + } + + _updateTime () { + requestAnimationFrame(() => { + if (!this.video) return; + + this.handleTimeUpdate(); + + if (!this.state.paused) { + this._updateTime(); + } + }); + } + + handleTimeUpdate = () => { + this.setState({ + currentTime: this.video.currentTime, + duration: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 { x } = getPointerPosition(this.volume, e); + + if(!isNaN(x)) { + this.setState({ volume: x }, () => { + this.video.volume = x; + }); + } + }, 15); + + 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 = this.video.duration * x; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.video.currentTime = currentTime; + }); + } + }, 15); + + seekBy (time) { + const currentTime = this.video.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.video.currentTime = currentTime; + }); + } + } + + handleVideoKeyDown = e => { + // On the video element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + const frameTime = 1 / this.getFrameRate(); + + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'f': + e.preventDefault(); + e.stopPropagation(); + this.toggleFullscreen(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + case ',': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-frameTime); + break; + case '.': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(frameTime); + break; + } + + // If we are in fullscreen mode, we don't want any hotkeys + // interacting with the UI that's not visible + + if (this.state.fullscreen) { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + exitFullscreen(); + } + } + } + + togglePlay = () => { + if (this.state.paused) { + this.setState({ paused: false }, () => this.video.play()); + } else { + this.setState({ paused: true }, () => 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); + + window.addEventListener('scroll', this.handleScroll); + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); + window.removeEventListener('resize', this.handleResize); + + document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (!this.state.paused && this.video && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } + } + + 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(); + } + } + + handleResize = debounce(() => { + if (this.player) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + handleScroll = throttle(() => { + if (!this.video) { + return; + } + + const { top, height } = this.video.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.video.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } + + this.setState({ paused: true }); + } + }, 150, { trailing: true }) + + handleFullscreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + toggleMute = () => { + const muted = !this.video.muted; + + this.setState({ muted }, () => { + this.video.muted = muted; + }); + } + + toggleReveal = () => { + if (this.state.revealed) { + this.setState({ paused: true }); + } + + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ revealed: !this.state.revealed }); + } + } + + handleLoadedData = () => { + const { currentTime, volume, muted, autoPlay } = this.props; + + if (currentTime) { + this.video.currentTime = currentTime; + } + + if (volume !== undefined) { + this.video.volume = volume; + } + + if (muted !== undefined) { + this.video.muted = muted; + } + + if (autoPlay) { + this.video.play(); + } + } + + handleProgress = () => { + const lastTimeRange = this.video.buffered.length - 1; + + if (lastTimeRange > -1) { + this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) }); + } + } + + handleVolumeChange = () => { + this.setState({ volume: this.video.volume, muted: this.video.muted }); + } + + handleOpenVideo = () => { + this.video.pause(); + + this.props.onOpenVideo({ + startTime: this.video.currentTime, + autoPlay: !this.state.paused, + defaultVolume: this.state.volume, + componetIndex: this.props.componetIndex, + }); + } + + handleCloseVideo = () => { + this.video.pause(); + this.props.onCloseVideo(); + } + + getFrameRate () { + if (this.props.frameRate && isNaN(this.props.frameRate)) { + // The frame rate is returned as a fraction string so we + // need to convert it to a number + + return this.props.frameRate.split('/').reduce((p, c) => p / c); + } + + return this.props.frameRate || 25; + } + + render () { + const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, editable, blurhash } = this.props; + const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; + const progress = Math.min((currentTime / duration) * 100, 100); + const playerStyle = {}; + + const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, 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 preload; + + if (this.props.currentTime || fullscreen || dragging) { + preload = 'auto'; + } else if (detailed) { + preload = 'metadata'; + } else { + preload = 'none'; + } + + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + return ( + <div + className={computedClass} + style={playerStyle} + ref={this.setPlayerRef} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + onClick={this.handleClickRoot} + onKeyDown={this.handleKeyDown} + tabIndex={0} + > + <Blurhash + hash={blurhash} + className={classNames('media-gallery__preview', { + 'media-gallery__preview--hidden': revealed, + })} + dummy={!useBlurhash} + /> + + {(revealed || editable) && <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} + onKeyDown={this.handleVideoKeyDown} + onPlay={this.handlePlay} + onPause={this.handlePause} + onLoadedData={this.handleLoadedData} + onProgress={this.handleProgress} + onVolumeChange={this.handleVolumeChange} + />} + + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}> + <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}%` }} + onKeyDown={this.handleVideoKeyDown} + /> + </div> + + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + + <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> + <div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} /> + + <span + className={classNames('video-player__volume__handle')} + tabIndex='0' + style={{ left: `${volume * 100}%` }} + /> + </div> + + {(detailed || fullscreen) && ( + <span className='video-player__time'> + <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span> + </span> + )} + </div> + + <div className='video-player__buttons right'> + {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} + {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} + <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></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/ast.js b/app/javascript/flavours/glitch/locales/ast.js new file mode 100644 index 000000000..41355c24c --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ast.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ast.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/bn.js b/app/javascript/flavours/glitch/locales/bn.js new file mode 100644 index 000000000..a453498b3 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/bn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/bn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/br.js b/app/javascript/flavours/glitch/locales/br.js new file mode 100644 index 000000000..966bd1b2f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/br.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/br.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/co.js b/app/javascript/flavours/glitch/locales/co.js new file mode 100644 index 000000000..6e9e46797 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/co.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/co.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/cs.js b/app/javascript/flavours/glitch/locales/cs.js new file mode 100644 index 000000000..ac7db0327 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/cs.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/cs.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/cy.js b/app/javascript/flavours/glitch/locales/cy.js new file mode 100644 index 000000000..09412bd72 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/cy.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/cy.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/da.js b/app/javascript/flavours/glitch/locales/da.js new file mode 100644 index 000000000..2b08806be --- /dev/null +++ b/app/javascript/flavours/glitch/locales/da.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/da.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/el.js b/app/javascript/flavours/glitch/locales/el.js new file mode 100644 index 000000000..2d9bb829f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/el.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/el.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-AR.js b/app/javascript/flavours/glitch/locales/es-AR.js new file mode 100644 index 000000000..0dffabcd4 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/es-AR.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/es-AR.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..086873881 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/es.js @@ -0,0 +1,119 @@ +import inherited from 'mastodon/locales/es.json'; + +const messages = { + 'advanced_options.icon_title': 'Opciones avanzadas', + 'advanced_options.local-only.long': 'No publicar a otras instancias', + 'advanced_options.local-only.short': 'Local', + 'advanced_options.local-only.tooltip': 'Este toot es local', + 'advanced_options.threaded_mode.long': 'Al publicar abre automáticamente una respuesta', + 'advanced_options.threaded_mode.short': 'Modo hilo', + 'advanced_options.threaded_mode.tooltip': 'Modo hilo habilitado', + 'compose.attach.doodle': 'Dibujar algo', + 'compose.attach.upload': 'Subir un archivo', + 'compose.attach': 'Adjuntar...', + 'favourite_modal.combo': 'Puedes presionar {combo} para omitir esto la próxima vez', + 'getting_started.onboarding': 'Paseo inicial', + 'getting_started.open_source_notice': 'Glitchsoc es software libre y gratuito bifurcado de {Mastodon}. Puedes contribuir o reportar errores en GitHub en {github}.', + 'home.column_settings.show_direct': 'Mostrar mensajes directos', + 'layout.auto': 'Automático', + 'layout.current_is': 'Tu diseño actual es:', + 'layout.desktop': 'Escritorio', + 'layout.hint.auto': 'Seleccionar un diseño automáticamente basado en "Habilitar interface web avanzada" y tamaño de pantalla', + 'layout.hint.desktop': 'Utiliza el diseño multi-columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla', + 'layout.hint.single': 'Utiliza el diseño de una columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla', + 'layout.mobile': 'Móvil', + 'media_gallery.sensitive': 'Sensible', + 'navigation_bar.app_settings': 'Ajustes de aplicación', + 'notification_purge.btn_all': 'Seleccionar\ntodo', + 'notification_purge.btn_apply': 'Borrar\nselección', + 'notification_purge.btn_invert': 'Invertir\nselección', + 'notification_purge.btn_none': 'Seleccionar\nnada', + 'notification.markForDeletion': 'Marcar para borrar', + 'notifications.clear': 'Limpiar notificaciones', + 'notifications.marked_clear_confirmation': '¿Deseas borrar permanentemente todas las notificaciones seleccionadas?', + 'notifications.marked_clear': 'Limpiar notificaciones seleccionadas', + 'onboarding.page_one.federation': '{domain} es una "instancia" de Mastodon. Mastodon es una red de servidores independientes que se unen para crear una red social más grande. A estos servidores los llamamos instancias.', + 'onboarding.page_one.welcome': '¡Bienvenidx a {domain}!', + 'onboarding.page_six.github': '{domain} usa Glitchsoc. Glitchsoc es una bifurcación {fork} amigable de {Mastodon}, y es compatible con cualquier instancia o aplicación de Mastodon. Glitchsoc es completamente gratuito y de código abierto. Puedes reportar errores, solicitar funciones o contribuir al código en {github}.', + 'settings.always_show_spoilers_field': 'Siempre mostrar el campo de advertencia de contenido', + 'settings.auto_collapse_all': 'Todo', + 'settings.auto_collapse_lengthy': 'Toots largos', + 'settings.auto_collapse_media': 'Toots con medios', + 'settings.auto_collapse_notifications': 'Notificaciones', + 'settings.auto_collapse_reblogs': 'Retoots', + 'settings.auto_collapse_replies': 'Respuestas', + 'settings.auto_collapse': 'Colapsar automáticamente', + 'settings.close': 'Cerrar', + 'settings.collapsed_statuses': 'Toots colapsados', + 'settings.compose_box_opts': 'Cuadro de redacción', + 'settings.confirm_before_clearing_draft': 'Mostrar diálogo de confirmación antes de sobreescribir un mensaje estabas escribiendo', + 'settings.confirm_boost_missing_media_description': 'Mostrar diálogo de confirmación antes de retootear publicaciones con medios sin descripción', + 'settings.confirm_missing_media_description': 'Mostrar diálogo de confirmación antes de publicar toots con medios sin descripción', + 'settings.content_warnings_filter': 'No descolapsar estas advertencias de contenido:', + 'settings.content_warnings.regexp': 'Regexp (expresión regular)', + 'settings.content_warnings': 'Advertencias de contenido', + 'settings.enable_collapsed': 'Habilitar toots colapsados', + 'settings.enable_content_warnings_auto_unfold': 'Descolapsar automáticamente advertencias de contenido', + 'settings.filtering_behavior.cw': 'Mostrar el toot y agregar las palabras filtradas a la advertencia de contenido', + 'settings.filtering_behavior.drop': 'Ocultar toots filtrados por completo', + 'settings.filtering_behavior.hide': 'Mostrar "Filtrado" y agregar un botón para saber por qué', + 'settings.filtering_behavior.upstream': 'Mostrar "Filtrado"', + 'settings.filtering_behavior': 'Configuración de filtros', + 'settings.filters': 'Filtros', + 'settings.general': 'General', + 'settings.hicolor_privacy_icons': 'Íconos de privacidad más visibles', + 'settings.image_backgrounds_media': 'Vista previa de medios de toots colapsados', + 'settings.image_backgrounds_users': 'Darle fondo de imagen a toots colapsados', + 'settings.image_backgrounds': 'Fondos de imágenes', + 'settings.inline_preview_cards': 'Vista previa para enlaces externos', + 'settings.layout_opts': 'Opciones de diseño', + 'settings.layout': 'Diseño', + 'settings.media_fullwidth': 'Ancho completo al mostrar medios ', + 'settings.media_letterbox_hint': 'Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos', + 'settings.media_letterbox': 'Mantener proporciones al mostrar medios', + 'settings.media_reveal_behind_cw': 'Siempre mostrar medios sensibles dentro de las advertencias de contenido', + 'settings.media': 'Medios', + 'settings.navbar_under': 'Barra de navegación en la parte inferior (solo móvil)', + 'settings.notifications_opts': 'Opciones de notificaciones', + 'settings.notifications.favicon_badge.hint': 'Muestra un marcador de notificaciones sin leer en el favicon', + 'settings.notifications.favicon_badge': 'Marcador de notificaciones en el favicon', + 'settings.notifications.tab_badge.hint': 'Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta', + 'settings.notifications.tab_badge': 'Marcador de notificaciones no leídas', + 'settings.preferences': 'Preferencias de usuarix', + 'settings.prepend_cw_re': 'Anteponer "re: " a las advertencias de contenido al responder', + 'settings.preselect_on_reply_hint': 'Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuarix subsecuentes del/la primerx', + 'settings.preselect_on_reply': 'Preseleccionar nombres de usuarix al responder', + 'settings.rewrite_mentions_acct': 'Reescribir con nombre de usuarix y dominio (para cuentas remotas)', + 'settings.rewrite_mentions_no': 'No reescribir menciones', + 'settings.rewrite_mentions_username': 'Reescribir con nombre de usuarix', + 'settings.rewrite_mentions': 'Reescribir menciones in publicaciones mostradas', + 'settings.show_action_bar': 'Mostrar botones de acción en toots colapsados', + 'settings.show_content_type_choice': 'Mostrar selección de tipo de contenido al crear toots', + 'settings.show_reply_counter': 'Mostrar un conteo estimado de respuestas', + 'settings.side_arm_reply_mode.copy': 'Copiar opción de privacidad del toot al que estás respondiendo', + 'settings.side_arm_reply_mode.keep': 'Conservar opción de privacidad', + 'settings.side_arm_reply_mode.restrict': 'Restringir la opción de privacidad a la misma del toot al que estás respondiendo', + 'settings.side_arm_reply_mode': 'Al responder a un toot:', + 'settings.side_arm.none': 'Ninguno', + 'settings.side_arm': 'Botón secundario:', + 'settings.swipe_to_change_columns': 'Permitir deslizar para cambiar columnas (Sólo en móvil)', + 'settings.tag_misleading_links.hint': 'Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente', + 'settings.tag_misleading_links': 'Marcar enlaces engañosos', + 'settings.wide_view': 'Vista amplia (solo modo de escritorio)', + 'status.collapse': 'Colapsar', + 'status.uncollapse': 'Descolapsar', + 'confirmations.unfilter': 'Información del toot filtrado', + 'confirmations.unfilter.author': 'Publicado por', + 'confirmations.unfilter.filters': 'Coincidencia con {count, plural, one {filtro} other {filtros}}', + 'confirmations.unfilter.edit_filter': 'Editar filtro', + 'confirmations.unfilter.confirm': 'Mostrar', + 'confirmations.delete.confirm': 'Borrar', + 'confirmations.delete.message': 'Por favor confirma borrado', + 'confirmations.redraft.confirm': 'Borrar y volver a borrador', + 'confirmations.redraft.message': '¿Borrar y volver a borrador? Perderás todas las respuestas, retoots y favoritos asociados, y las respuestas a la publicación original quedarán huérfanos.', + 'confirmations.reply.confirm': 'Responder', + 'confirmations.reply.message': 'Responder no sobreescribirá el mensaje que estás escibiendo actualmente. ¿Deseas continuar?', + 'status.show_filter_reason': '(mostrar por qué)', +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/et.js b/app/javascript/flavours/glitch/locales/et.js new file mode 100644 index 000000000..e3ea6b2a9 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/et.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/et.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/eu.js b/app/javascript/flavours/glitch/locales/eu.js new file mode 100644 index 000000000..946410b67 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/eu.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/eu.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/ga.js b/app/javascript/flavours/glitch/locales/ga.js new file mode 100644 index 000000000..af2846ff8 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ga.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ga.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/gl.js b/app/javascript/flavours/glitch/locales/gl.js new file mode 100644 index 000000000..6a9140b1a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/gl.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/gl.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/hi.js b/app/javascript/flavours/glitch/locales/hi.js new file mode 100644 index 000000000..1a569495f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/hi.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/hi.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/hy.js b/app/javascript/flavours/glitch/locales/hy.js new file mode 100644 index 000000000..96f6a4d19 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/hy.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/hy.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/is.js b/app/javascript/flavours/glitch/locales/is.js new file mode 100644 index 000000000..b05a08ad0 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/is.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/is.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/ka.js b/app/javascript/flavours/glitch/locales/ka.js new file mode 100644 index 000000000..3e06f4282 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ka.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ka.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/kab.js b/app/javascript/flavours/glitch/locales/kab.js new file mode 100644 index 000000000..5ed1156ef --- /dev/null +++ b/app/javascript/flavours/glitch/locales/kab.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/kab.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/kk.js b/app/javascript/flavours/glitch/locales/kk.js new file mode 100644 index 000000000..8d00fb035 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/kk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/kk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/kn.js b/app/javascript/flavours/glitch/locales/kn.js new file mode 100644 index 000000000..1c50e3628 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/kn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/kn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); 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/ku.js b/app/javascript/flavours/glitch/locales/ku.js new file mode 100644 index 000000000..19e0e95aa --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ku.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ku.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/lt.js b/app/javascript/flavours/glitch/locales/lt.js new file mode 100644 index 000000000..47453aeeb --- /dev/null +++ b/app/javascript/flavours/glitch/locales/lt.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/lt.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/lv.js b/app/javascript/flavours/glitch/locales/lv.js new file mode 100644 index 000000000..cdbcdf799 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/lv.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/lv.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/mk.js b/app/javascript/flavours/glitch/locales/mk.js new file mode 100644 index 000000000..55e510b59 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/mk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/mk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ml.js b/app/javascript/flavours/glitch/locales/ml.js new file mode 100644 index 000000000..d00331a1a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ml.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ml.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/mr.js b/app/javascript/flavours/glitch/locales/mr.js new file mode 100644 index 000000000..fb3cde92a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/mr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/mr.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ms.js b/app/javascript/flavours/glitch/locales/ms.js new file mode 100644 index 000000000..61033c521 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ms.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ms.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/nn.js b/app/javascript/flavours/glitch/locales/nn.js new file mode 100644 index 000000000..4c42368cb --- /dev/null +++ b/app/javascript/flavours/glitch/locales/nn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/nn.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-PT.js b/app/javascript/flavours/glitch/locales/pt-PT.js new file mode 100644 index 000000000..cf7afd17a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/pt-PT.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/pt-PT.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ro.js b/app/javascript/flavours/glitch/locales/ro.js new file mode 100644 index 000000000..a16446c6a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ro.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ro.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/sa.js b/app/javascript/flavours/glitch/locales/sa.js new file mode 100644 index 000000000..4cade0a07 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sa.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sa.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sc.js b/app/javascript/flavours/glitch/locales/sc.js new file mode 100644 index 000000000..88a83aa53 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sc.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sc.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sk.js b/app/javascript/flavours/glitch/locales/sk.js new file mode 100644 index 000000000..5fba6ab97 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sl.js b/app/javascript/flavours/glitch/locales/sl.js new file mode 100644 index 000000000..c53c1bae8 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sl.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sl.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sq.js b/app/javascript/flavours/glitch/locales/sq.js new file mode 100644 index 000000000..2fb7a2973 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sq.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sq.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sr-Latn.js b/app/javascript/flavours/glitch/locales/sr-Latn.js new file mode 100644 index 000000000..b42d5eaaf --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sr-Latn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sr-Latn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sr.js b/app/javascript/flavours/glitch/locales/sr.js new file mode 100644 index 000000000..8793d8d1e --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sr.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/ta.js b/app/javascript/flavours/glitch/locales/ta.js new file mode 100644 index 000000000..d6ecdcb1b --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ta.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ta.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/te.js b/app/javascript/flavours/glitch/locales/te.js new file mode 100644 index 000000000..afd6e4f7b --- /dev/null +++ b/app/javascript/flavours/glitch/locales/te.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/te.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/ur.js b/app/javascript/flavours/glitch/locales/ur.js new file mode 100644 index 000000000..97ba291b0 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ur.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ur.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/vi.js b/app/javascript/flavours/glitch/locales/vi.js new file mode 100644 index 000000000..499a96727 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/vi.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/vi.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/zgh.js b/app/javascript/flavours/glitch/locales/zgh.js new file mode 100644 index 000000000..f2f15b1a4 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/zgh.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/zgh.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..ade529a4e --- /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, action.skipNotFound)); + } + } + + 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..0d56fd3cc --- /dev/null +++ b/app/javascript/flavours/glitch/names.yml @@ -0,0 +1,23 @@ +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 +es: + flavours: + glitch: + description: El diseño predeterminado para las instancias con GlitchSoc. + name: Glitchsoc + skins: + glitch: + default: Predeterminado diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js new file mode 100644 index 000000000..2e2cce501 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/about.js @@ -0,0 +1,23 @@ +import 'packs/public-path'; +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..7dc34eba9 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/common.js @@ -0,0 +1,9 @@ +import 'packs/public-path'; +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..9f692ad37 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/error.js @@ -0,0 +1,14 @@ +import 'packs/public-path'; +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..d06688985 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/home.js @@ -0,0 +1,8 @@ +import 'packs/public-path'; +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..dccdbc8d0 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/public.js @@ -0,0 +1,172 @@ +import 'packs/public-path'; +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; +import ready from 'flavours/glitch/util/ready'; +import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; + +function main() { + const IntlMessageFormat = require('intl-messageformat').default; + const { timeAgoString } = require('flavours/glitch/components/relative_timestamp'); + const { delegate } = require('@rails/ujs'); + 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 { createBrowserHistory } = require('history'); + + const scrollToDetailedStatus = () => { + const history = createBrowserHistory(); + 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 }); + } + }; + + const getEmojiAnimationHandler = (swapTo) => { + return ({ target }) => { + target.src = target.getAttribute(swapTo); + }; + }; + + 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(), content.getAttribute('datetime').includes('T')); + }); + + 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 }); + } + + delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => { + const password = document.getElementById('registration_user_password'); + const confirmation = document.getElementById('registration_user_password_confirmation'); + if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format()); + } else { + confirmation.setCustomValidity(''); + } + }); + + delegate(document, '#user_password,#user_password_confirmation', 'input', () => { + const password = document.getElementById('user_password'); + const confirmation = document.getElementById('user_password_confirmation'); + if (!confirmation) return; + + if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format()); + } else { + confirmation.setCustomValidity(''); + } + }); + + delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); + delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); + + delegate(document, '.status__content__spoiler-link', 'click', function() { + const statusEl = this.parentNode.parentNode; + + if (statusEl.dataset.spoiler === 'expanded') { + statusEl.dataset.spoiler = 'folded'; + this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format(); + } else { + statusEl.dataset.spoiler = 'expanded'; + this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format(); + } + + return false; + }); + + [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => { + const statusEl = spoilerLink.parentNode.parentNode; + const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more'); + spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format(); + }); + }); + + delegate(document, '.sidebar__toggle__icon', 'click', () => { + const target = document.querySelector('.sidebar ul'); + + if (target.style.display === 'block') { + target.style.display = 'none'; + } else { + target.style.display = 'block'; + } + }); + + // Empty the honeypot fields in JS in case something like an extension + // automatically filled them. + delegate(document, '#registration_new_user,#new_user', 'submit', () => { + ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => { + const field = document.getElementById(id); + if (field) { + field.value = ''; + } + }); + }); +} + +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js new file mode 100644 index 000000000..9c4d119c1 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/settings.js @@ -0,0 +1,25 @@ +import 'packs/public-path'; +import loadPolyfills from 'flavours/glitch/util/load_polyfills'; +import ready from 'flavours/glitch/util/ready'; +import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; + +function main() { + const { delegate } = require('@rails/ujs'); + + delegate(document, '.sidebar__toggle__icon', 'click', () => { + const target = document.querySelector('.sidebar ul'); + + if (target.style.display === 'block') { + target.style.display = 'none'; + } else { + target.style.display = 'block'; + } + }); +} + +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .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..f4a97e201 --- /dev/null +++ b/app/javascript/flavours/glitch/packs/share.js @@ -0,0 +1,23 @@ +import 'packs/public-path'; +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/account_notes.js b/app/javascript/flavours/glitch/reducers/account_notes.js new file mode 100644 index 000000000..b1cf2e0aa --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/account_notes.js @@ -0,0 +1,44 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { + ACCOUNT_NOTE_INIT_EDIT, + ACCOUNT_NOTE_CANCEL, + ACCOUNT_NOTE_CHANGE_COMMENT, + ACCOUNT_NOTE_SUBMIT_REQUEST, + ACCOUNT_NOTE_SUBMIT_FAIL, + ACCOUNT_NOTE_SUBMIT_SUCCESS, +} from '../actions/account_notes'; + +const initialState = ImmutableMap({ + edit: ImmutableMap({ + isSubmitting: false, + account_id: null, + comment: null, + }), +}); + +export default function account_notes(state = initialState, action) { + switch (action.type) { + case ACCOUNT_NOTE_INIT_EDIT: + return state.withMutations((state) => { + state.setIn(['edit', 'isSubmitting'], false); + state.setIn(['edit', 'account_id'], action.account.get('id')); + state.setIn(['edit', 'comment'], action.comment); + }); + case ACCOUNT_NOTE_CHANGE_COMMENT: + return state.setIn(['edit', 'comment'], action.comment); + case ACCOUNT_NOTE_SUBMIT_REQUEST: + return state.setIn(['edit', 'isSubmitting'], true); + case ACCOUNT_NOTE_SUBMIT_FAIL: + return state.setIn(['edit', 'isSubmitting'], false); + case ACCOUNT_NOTE_SUBMIT_SUCCESS: + case ACCOUNT_NOTE_CANCEL: + return state.withMutations((state) => { + state.setIn(['edit', 'isSubmitting'], false); + state.setIn(['edit', 'account_id'], null); + state.setIn(['edit', 'comment'], null); + }); + default: + return state; + } +} 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..ee3d54ab0 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/alerts.js @@ -0,0 +1,26 @@ +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, + message_values: action.message_values, + })); + 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/announcements.js b/app/javascript/flavours/glitch/reducers/announcements.js new file mode 100644 index 000000000..34e08eac8 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/announcements.js @@ -0,0 +1,102 @@ +import { + ANNOUNCEMENTS_FETCH_REQUEST, + ANNOUNCEMENTS_FETCH_SUCCESS, + ANNOUNCEMENTS_FETCH_FAIL, + ANNOUNCEMENTS_UPDATE, + ANNOUNCEMENTS_REACTION_UPDATE, + ANNOUNCEMENTS_REACTION_ADD_REQUEST, + ANNOUNCEMENTS_REACTION_ADD_FAIL, + ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + ANNOUNCEMENTS_TOGGLE_SHOW, + ANNOUNCEMENTS_DELETE, + ANNOUNCEMENTS_DISMISS_SUCCESS, +} from '../actions/announcements'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + show: false, +}); + +const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => { + if (announcement.get('id') === id) { + return announcement.update('reactions', reactions => { + const idx = reactions.findIndex(reaction => reaction.get('name') === name); + + if (idx > -1) { + return reactions.update(idx, reaction => updater(reaction)); + } + + return reactions.push(updater(fromJS({ name, count: 0 }))); + }); + } + + return announcement; +})); + +const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count)); + +const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1)); + +const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1)); + +const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at')); + +const updateAnnouncement = (state, announcement) => { + const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id')); + + if (idx > -1) { + // Deep merge is used because announcements from the streaming API do not contain + // personalized data about which reactions have been selected by the given user, + // and that is information we want to preserve + return state.update('items', list => sortAnnouncements(list.update(idx, x => x.mergeDeep(announcement)))); + } + + return state.update('items', list => sortAnnouncements(list.unshift(announcement))); +}; + +export default function announcementsReducer(state = initialState, action) { + switch(action.type) { + case ANNOUNCEMENTS_TOGGLE_SHOW: + return state.withMutations(map => { + map.set('show', !map.get('show')); + }); + case ANNOUNCEMENTS_FETCH_REQUEST: + return state.set('isLoading', true); + case ANNOUNCEMENTS_FETCH_SUCCESS: + return state.withMutations(map => { + const items = fromJS(action.announcements); + + map.set('items', items); + map.set('isLoading', false); + }); + case ANNOUNCEMENTS_FETCH_FAIL: + return state.set('isLoading', false); + case ANNOUNCEMENTS_UPDATE: + return updateAnnouncement(state, fromJS(action.announcement)); + case ANNOUNCEMENTS_REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case ANNOUNCEMENTS_REACTION_ADD_REQUEST: + case ANNOUNCEMENTS_REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name); + case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: + case ANNOUNCEMENTS_REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); + case ANNOUNCEMENTS_DISMISS_SUCCESS: + return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true })); + case ANNOUNCEMENTS_DELETE: + return state.update('items', list => { + const idx = list.findIndex(x => x.get('id') === action.id); + + if (idx > -1) { + return list.delete(idx); + } + + return list; + }); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/blocks.js b/app/javascript/flavours/glitch/reducers/blocks.js new file mode 100644 index 000000000..1b6507163 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/blocks.js @@ -0,0 +1,22 @@ +import Immutable from 'immutable'; + +import { + BLOCKS_INIT_MODAL, +} from '../actions/blocks'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + account_id: null, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case BLOCKS_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'account_id'], action.account.get('id')); + }); + default: + return state; + } +} diff --git a/app/javascript/flavours/glitch/reducers/boosts.js b/app/javascript/flavours/glitch/reducers/boosts.js new file mode 100644 index 000000000..3541ca0c2 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/boosts.js @@ -0,0 +1,25 @@ +import Immutable from 'immutable'; + +import { + BOOSTS_INIT_MODAL, + BOOSTS_CHANGE_PRIVACY, +} from 'flavours/glitch/actions/boosts'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + privacy: 'public', + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case BOOSTS_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'privacy'], action.privacy); + }); + case BOOSTS_CHANGE_PRIVACY: + return state.setIn(['new', 'privacy'], action.privacy); + 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..e989401d8 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -0,0 +1,553 @@ +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, + THUMBNAIL_UPLOAD_REQUEST, + THUMBNAIL_UPLOAD_SUCCESS, + THUMBNAIL_UPLOAD_FAIL, + THUMBNAIL_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_CONTENT_TYPE_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, defaultContentType } 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: 0, + 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, + content_type: defaultContentType || 'text/plain', + text: '', + focusDate: null, + caretPosition: null, + preselectDate: null, + in_reply_to: null, + is_submitting: false, + is_uploading: false, + is_changing_upload: false, + progress: 0, + isUploadingThumbnail: false, + thumbnailProgress: 0, + media_attachments: ImmutableList(), + pending_media_attachments: 0, + 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', ''); + if (defaultContentType) map.set('content_type', defaultContentType); + 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: status.local_only })) + ); + map.set('privacy', status.visibility); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('poll', null); + map.set('idempotencyKey', uuid()); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + }); +} + +function appendMedia(state, media, file) { + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + if (media.get('type') === 'image') { + media = media.set('file', file); + } + 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()); + map.update('pending_media_attachments', n => n - 1); + + 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 sortHashtagsByUse = (state, tags) => { + const personalHistory = state.get('tagHistory'); + + return tags.sort((a, b) => { + const usedA = personalHistory.includes(a.name); + const usedB = personalHistory.includes(b.name); + + if (usedA === usedB) { + return 0; + } else if (usedA && !usedB) { + return 1; + } else { + return -1; + } + }); +}; + +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; +}; + +const expiresInFromExpiresAt = expires_at => { + if (!expires_at) return 24 * 3600; + const delta = (new Date(expires_at).getTime() - Date.now()) / 1000; + return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600; +}; + +const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { + prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { + const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); + return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); + } else { + return suggestions; + } +}; + +const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { + if (accounts) { + return accounts.map(item => ({ id: item.id, type: 'account' })); + } else if (emojis) { + return emojis.map(item => ({ ...item, type: 'emoji' })); + } else { + return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory')); + } +}; + +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + const suggestions = state.get('suggestions').toJS(); + return state.merge({ + suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))), + suggestion_token: token, + }); +}; + +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', state.get('mounted') + 1); + case COMPOSE_UNMOUNT: + return state.set('mounted', Math.max(state.get('mounted') - 1, 0)); + 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', !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_CONTENT_TYPE_CHANGE: + return state + .set('content_type', 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: !!action.status.get('local_only') })) + ); + 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 (action.prependCWRe && !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); + if (defaultContentType) map.set('content_type', defaultContentType); + 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).update('pending_media_attachments', n => n + 1); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, fromJS(action.media), action.file); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false).update('pending_media_attachments', n => n - 1); + 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 THUMBNAIL_UPLOAD_REQUEST: + return state.set('isUploadingThumbnail', true); + case THUMBNAIL_UPLOAD_PROGRESS: + return state.set('thumbnailProgress', Math.round((action.loaded / action.total) * 100)); + case THUMBNAIL_UPLOAD_FAIL: + return state.set('isUploadingThumbnail', false); + case THUMBNAIL_UPLOAD_SUCCESS: + return state + .set('isUploadingThumbnail', false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return fromJS(action.media); + } + + return item; + })); + 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(normalizeSuggestions(state, action))).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: + const do_not_federate = !!action.status.get('local_only'); + let text = action.raw_text || unescapeHTML(expandMentions(action.status)); + if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, ''); + return state.withMutations(map => { + map.set('text', text); + map.set('content_type', action.content_type || 'text/plain'); + 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()); + map.set('sensitive', action.status.get('sensitive')); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate })) + ); + + 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: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); + 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/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js new file mode 100644 index 000000000..fba0308bc --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/conversations.js @@ -0,0 +1,116 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + CONVERSATIONS_MOUNT, + CONVERSATIONS_UNMOUNT, + CONVERSATIONS_FETCH_REQUEST, + CONVERSATIONS_FETCH_SUCCESS, + CONVERSATIONS_FETCH_FAIL, + CONVERSATIONS_UPDATE, + CONVERSATIONS_READ, + CONVERSATIONS_DELETE_SUCCESS, +} from '../actions/conversations'; +import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; +import compareId from 'flavours/glitch/util/compare_id'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + hasMore: true, + mounted: 0, +}); + +const conversationToMap = item => ImmutableMap({ + id: item.id, + unread: item.unread, + accounts: ImmutableList(item.accounts.map(a => a.id)), + last_status: item.last_status ? item.last_status.id : null, +}); + +const updateConversation = (state, item) => state.update('items', list => { + const index = list.findIndex(x => x.get('id') === item.id); + const newItem = conversationToMap(item); + + if (index === -1) { + return list.unshift(newItem); + } else { + return list.set(index, newItem); + } +}); + +const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => { + let items = ImmutableList(conversations.map(conversationToMap)); + + return state.withMutations(mutable => { + if (!items.isEmpty()) { + mutable.update('items', list => { + list = list.map(oldItem => { + const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id')); + + if (newItemIndex === -1) { + return oldItem; + } + + const newItem = items.get(newItemIndex); + items = items.delete(newItemIndex); + + return newItem; + }); + + list = list.concat(items); + + return list.sortBy(x => x.get('last_status'), (a, b) => { + if(a === null || b === null) { + return -1; + } + + return compareId(a, b) * -1; + }); + }); + } + + if (!next && !isLoadingRecent) { + mutable.set('hasMore', false); + } + + mutable.set('isLoading', false); + }); +}; + +const filterConversations = (state, accountIds) => { + return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId)))); +}; + +export default function conversations(state = initialState, action) { + switch (action.type) { + case CONVERSATIONS_FETCH_REQUEST: + return state.set('isLoading', true); + case CONVERSATIONS_FETCH_FAIL: + return state.set('isLoading', false); + case CONVERSATIONS_FETCH_SUCCESS: + return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent); + case CONVERSATIONS_UPDATE: + return updateConversation(state, action.conversation); + case CONVERSATIONS_MOUNT: + return state.update('mounted', count => count + 1); + case CONVERSATIONS_UNMOUNT: + return state.update('mounted', count => count - 1); + case CONVERSATIONS_READ: + return state.update('items', list => list.map(item => { + if (item.get('id') === action.id) { + return item.set('unread', false); + } + + return item; + })); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterConversations(state, [action.relationship.id]); + case DOMAIN_BLOCK_SUCCESS: + return filterConversations(state, action.accounts); + case CONVERSATIONS_DELETE_SUCCESS: + return state.update('items', list => list.filterNot(item => item.get('id') === action.id)); + 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..a78a11acc --- /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, scroll_key: null }); + +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, scroll_key: action.scroll_key }); + case DROPDOWN_MENU_CLOSE: + return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', 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..c452e834c --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -0,0 +1,88 @@ +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 blocks from './blocks'; +import reports from './reports'; +import boosts from './boosts'; +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 conversations from './conversations'; +import suggestions from './suggestions'; +import pinnedAccountsEditor from './pinned_accounts_editor'; +import polls from './polls'; +import identity_proofs from './identity_proofs'; +import trends from './trends'; +import announcements from './announcements'; +import markers from './markers'; +import account_notes from './account_notes'; +import picture_in_picture from './picture_in_picture'; + +const reducers = { + announcements, + 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, + blocks, + reports, + boosts, + contexts, + compose, + search, + media_attachments, + notifications, + height_cache, + custom_emojis, + identity_proofs, + lists, + listEditor, + listAdder, + filters, + conversations, + suggestions, + pinnedAccountsEditor, + polls, + trends, + markers, + account_notes, + picture_in_picture, +}; + +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..c115cad6b --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -0,0 +1,72 @@ +// 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_boost_missing_media_description: false, + confirm_before_clearing_draft: true, + prepend_cw_re: true, + preselect_on_reply: true, + inline_preview_cards: true, + hicolor_privacy_icons: false, + show_content_type_choice: false, + filtering_behavior: 'hide', + tag_misleading_links: true, + rewrite_mentions: 'no', + 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, + pop_in_player : true, + pop_in_position : 'right', + }), + 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/markers.js b/app/javascript/flavours/glitch/reducers/markers.js new file mode 100644 index 000000000..fb1572ff5 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/markers.js @@ -0,0 +1,25 @@ +import { + MARKERS_SUBMIT_SUCCESS, +} from '../actions/markers'; + +const initialState = ImmutableMap({ + home: '0', + notifications: '0', +}); + +import { Map as ImmutableMap } from 'immutable'; + +export default function markers(state = initialState, action) { + switch(action.type) { + case MARKERS_SUBMIT_SUCCESS: + if (action.home) { + state = state.set('home', action.home); + } + if (action.notifications) { + state = state.set('notifications', action.notifications); + } + return state; + 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..52b05d69b --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -0,0 +1,20 @@ +import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal'; +import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; + +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 (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; + case TIMELINE_DELETE: + return (state.modalProps.statusId === action.id) ? initialState : state; + 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..d346d9a78 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -0,0 +1,31 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, +} from 'flavours/glitch/actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + account: null, + notifications: true, + duration: 0, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); + 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..859a8b3a1 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -0,0 +1,318 @@ +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_LOAD_PENDING, + 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, + NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, +} from 'flavours/glitch/actions/notifications'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_REJECT_SUCCESS, +} from 'flavours/glitch/actions/accounts'; +import { + MARKERS_FETCH_SUCCESS, +} from 'flavours/glitch/actions/markers'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; +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({ + pendingItems: ImmutableList(), + items: ImmutableList(), + hasMore: true, + top: false, + mounted: 0, + unread: 0, + lastReadId: '0', + readMarkerId: '0', + isLoading: false, + cleaningMode: false, + isTabVisible: true, + browserSupport: false, + browserPermission: 'default', + // 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, usePendingItems) => { + const top = state.get('top'); + + if (usePendingItems || !state.get('pendingItems').isEmpty()) { + return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification))).update('unread', unread => unread + 1); + } + + if (shouldCountUnreadNotifications(state)) { + state = state.update('unread', unread => unread + 1); + } else { + state = state.set('lastReadId', notification.id); + } + + 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, isLoadingRecent, usePendingItems) => { + 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()) { + usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('pendingItems').isEmpty()); + + mutable.update(usePendingItems ? 'pendingItems' : '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 (!next) { + mutable.set('hasMore', false); + } + + if (shouldCountUnreadNotifications(state)) { + mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), lastReadId) > 0)); + } else { + const mostRecent = items.find(item => item !== null); + if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) { + mutable.set('lastReadId', mostRecent.get('id')); + } + } + + mutable.set('isLoading', false); + }); +}; + +const filterNotifications = (state, accountIds, type) => { + const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type'))); + return state.update('items', helper).update('pendingItems', helper); +}; + +const clearUnread = (state) => { + state = state.set('unread', state.get('pendingItems').size); + 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; +}; + +const deleteByStatus = (state, statusId) => { + const lastReadId = state.get('lastReadId'); + + if (shouldCountUnreadNotifications(state)) { + 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); + } + + const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); + const deletedUnread = state.get('pendingItems').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', helper).update('pendingItems', helper); +}; + +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.get('mounted') === 1)) { + state = state.set('readMarkerId', state.get('lastReadId')); + state = clearUnread(state); + } + return state; +}; + +const updateVisibility = (state, visibility) => { + state = state.set('isTabVisible', visibility); + if (!shouldCountUnreadNotifications(state)) { + state = state.set('readMarkerId', state.get('lastReadId')); + state = clearUnread(state); + } + return state; +}; + +const shouldCountUnreadNotifications = (state, ignoreScroll = false) => { + const isTabVisible = state.get('isTabVisible'); + const isOnTop = state.get('top'); + const isMounted = state.get('mounted') > 0; + const lastReadId = state.get('lastReadId'); + const lastItem = state.get('items').findLast(item => item !== null); + const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); + + return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached); +}; + +const recountUnread = (state, last_read_id) => { + return state.withMutations(mutable => { + if (compareId(last_read_id, mutable.get('lastReadId')) > 0) { + mutable.set('lastReadId', last_read_id); + } + + if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) { + mutable.set('readMarkerId', last_read_id); + } + + if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) { + mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0)); + } + }); +}; + +export default function notifications(state = initialState, action) { + let st; + + switch(action.type) { + case MARKERS_FETCH_SUCCESS: + return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state; + 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_LOAD_PENDING: + return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); + 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, action.usePendingItems); + case NOTIFICATIONS_EXPAND_SUCCESS: + return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingRecent, action.usePendingItems); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, [action.relationship.id]); + case ACCOUNT_MUTE_SUCCESS: + return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state; + case DOMAIN_BLOCK_SUCCESS: + return filterNotifications(state, action.accounts); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return filterNotifications(state, [action.id], 'follow_request'); + case ACCOUNT_MUTE_SUCCESS: + return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state; + case NOTIFICATIONS_CLEAR: + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); + case TIMELINE_DELETE: + return deleteByStatus(state, action.id); + case TIMELINE_DISCONNECT: + return action.timeline === 'home' ? + state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : + state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); + + 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); + + case NOTIFICATIONS_MARK_AS_READ: + const lastNotification = state.get('items').find(item => item !== null); + return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; + + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js new file mode 100644 index 000000000..f552a59c2 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js @@ -0,0 +1,22 @@ +import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture'; + +const initialState = { + statusId: null, + accountId: null, + type: null, + src: null, + muted: false, + volume: 0, + currentTime: 0, +}; + +export default function pictureInPicture(state = initialState, action) { + switch(action.type) { + case PICTURE_IN_PICTURE_DEPLOY: + return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props }; + case PICTURE_IN_PICTURE_REMOVE: + return { ...initialState }; + 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..595f340bc --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/polls.js @@ -0,0 +1,15 @@ +import { POLLS_IMPORT } from 'flavours/glitch/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..117fb5167 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/push_notifications.js @@ -0,0 +1,53 @@ +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, + follow_request: 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..49dd77ef5 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/relationships.js @@ -0,0 +1,74 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_FOLLOW_REQUEST, + ACCOUNT_FOLLOW_FAIL, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_REQUEST, + ACCOUNT_UNFOLLOW_FAIL, + 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 { + ACCOUNT_NOTE_SUBMIT_SUCCESS, +} from 'flavours/glitch/actions/account_notes'; +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_REQUEST: + return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); + case ACCOUNT_FOLLOW_FAIL: + return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); + case ACCOUNT_UNFOLLOW_REQUEST: + return state.setIn([action.id, 'following'], false); + case ACCOUNT_UNFOLLOW_FAIL: + return state.setIn([action.id, 'following'], true); + 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: + case ACCOUNT_NOTE_SUBMIT_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..1f7f3f273 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/reports.js @@ -0,0 +1,77 @@ +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 { + TIMELINE_DELETE, +} from 'flavours/glitch/actions/timelines'; +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, + }), +}); + +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref[0], []); + }); + + return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.remove(id)); +}; + +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); + }); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + 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..c346e958b --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/search.js @@ -0,0 +1,52 @@ +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW, + SEARCH_EXPAND_SUCCESS, +} 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(), + searchTerm: '', +}); + +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).set('searchTerm', action.searchTerm); + case SEARCH_EXPAND_SUCCESS: + const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id); + return state.updateIn(['results', action.searchType], list => list.concat(results)); + 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..a53d34a83 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -0,0 +1,165 @@ +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, + + trends: ImmutableMap({ + show: true, + }), + + home: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + direct: true, + }), + + regex: ImmutableMap({ + body: '', + }), + }), + + notifications: ImmutableMap({ + alerts: ImmutableMap({ + follow: false, + follow_request: false, + favourite: false, + reblog: false, + mention: false, + poll: false, + status: false, + }), + + quickFilter: ImmutableMap({ + active: 'all', + show: true, + advanced: false, + }), + + dismissPermissionBanner: false, + showUnread: true, + + shows: ImmutableMap({ + follow: true, + follow_request: false, + favourite: true, + reblog: true, + mention: true, + poll: true, + status: true, + }), + + sounds: ImmutableMap({ + follow: true, + follow_request: false, + favourite: true, + reblog: true, + mention: true, + poll: true, + status: true, + }), + }), + + community: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + public: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), + + direct: ImmutableMap({ + conversations: true, + 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..5db766b96 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -0,0 +1,64 @@ +import { + REBLOG_REQUEST, + REBLOG_FAIL, + FAVOURITE_REQUEST, + FAVOURITE_FAIL, + UNFAVOURITE_SUCCESS, + 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, []); + }); + + 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 UNFAVOURITE_SUCCESS: + return state.updateIn([action.status.get('id'), 'favourites_count'], x => Math.max(0, x - 1)); + 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..2bc30d2c6 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/suggestions.js @@ -0,0 +1,37 @@ +import { + SUGGESTIONS_FETCH_REQUEST, + SUGGESTIONS_FETCH_SUCCESS, + SUGGESTIONS_FETCH_FAIL, + SUGGESTIONS_DISMISS, +} from '../actions/suggestions'; +import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; +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.suggestions.map(x => ({ ...x, account: x.account.id })))); + map.set('isLoading', false); + }); + case SUGGESTIONS_FETCH_FAIL: + return state.set('isLoading', false); + case SUGGESTIONS_DISMISS: + return state.update('items', list => list.filterNot(x => x.account === action.id)); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return state.update('items', list => list.filterNot(x => x.account === action.relationship.id)); + case DOMAIN_BLOCK_SUCCESS: + return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account))); + 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..7d815d850 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -0,0 +1,186 @@ +import { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_CLEAR, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, + TIMELINE_LOAD_PENDING, + TIMELINE_MARK_AS_PARTIAL, +} 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, + pendingItems: ImmutableList(), + items: ImmutableList(), +}); + +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { + 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 (timeline.endsWith(':pinned')) { + mMap.set('items', statuses.map(status => status.get('id'))); + } else if (!statuses.isEmpty()) { + usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty()); + + mMap.update(usePendingItems ? 'pendingItems' : '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, usePendingItems, filtered) => { + const top = state.getIn([timeline, 'top']); + + if (usePendingItems || !state.getIn([timeline, 'pendingItems']).isEmpty()) { + if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) { + return state; + } + + state = state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id')))); + + if (!filtered) { + state = state.updateIn([timeline, 'unread'], unread => unread + 1); + } + + return state; + } + + 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 && !filtered) 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, references, exclude_account = null) => { + state.keySeq().forEach(timeline => { + if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) { + const helper = list => list.filterNot(item => item === id); + state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper); + } + }); + + // Remove reblogs of deleted status + references.forEach(ref => { + state = deleteStatus(state, ref, [], 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')); + state = deleteStatus(state, status.get('id'), references, relationship.id); + }); + + return state; +}; + +const filterTimeline = (timeline, state, relationship, statuses) => { + const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id); + return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper); +}; + +const updateTop = (state, timeline, top) => { + return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { + if (top) mMap.set('unread', mMap.get('pendingItems').size); + mMap.set('top', top); + })); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_LOAD_PENDING: + return state.update(action.timeline, initialTimeline, map => + map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0)); + 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, action.usePendingItems); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, 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(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items), + ); + case TIMELINE_MARK_AS_PARTIAL: + return state.update( + action.timeline, + initialTimeline, + map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0), + ); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/trends.js b/app/javascript/flavours/glitch/reducers/trends.js new file mode 100644 index 000000000..5cecc8fca --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/trends.js @@ -0,0 +1,23 @@ +import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, +}); + +export default function trendsReducer(state = initialState, action) { + switch(action.type) { + case TRENDS_FETCH_REQUEST: + return state.set('isLoading', true); + case TRENDS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.trends)); + map.set('isLoading', false); + }); + case TRENDS_FETCH_FAIL: + return state.set('isLoading', false); + 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..bfddbd246 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -0,0 +1,166 @@ +import { + NOTIFICATIONS_UPDATE, +} from '../actions/notifications'; +import { + FOLLOWERS_FETCH_REQUEST, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_FETCH_FAIL, + FOLLOWERS_EXPAND_REQUEST, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWERS_EXPAND_FAIL, + FOLLOWING_FETCH_REQUEST, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_FETCH_FAIL, + FOLLOWING_EXPAND_REQUEST, + FOLLOWING_EXPAND_SUCCESS, + FOLLOWING_EXPAND_FAIL, + FOLLOW_REQUESTS_FETCH_REQUEST, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_FETCH_FAIL, + FOLLOW_REQUESTS_EXPAND_REQUEST, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUESTS_EXPAND_FAIL, + 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_REQUEST, + BLOCKS_FETCH_SUCCESS, + BLOCKS_FETCH_FAIL, + BLOCKS_EXPAND_REQUEST, + BLOCKS_EXPAND_SUCCESS, + BLOCKS_EXPAND_FAIL, +} from 'flavours/glitch/actions/blocks'; +import { + MUTES_FETCH_REQUEST, + MUTES_FETCH_SUCCESS, + MUTES_FETCH_FAIL, + MUTES_EXPAND_REQUEST, + MUTES_EXPAND_SUCCESS, + MUTES_EXPAND_FAIL, +} from 'flavours/glitch/actions/mutes'; +import { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, +} from 'flavours/glitch/actions/directory'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +const initialListState = ImmutableMap({ + next: null, + isLoading: false, + items: ImmutableList(), +}); + +const initialState = ImmutableMap({ + followers: initialListState, + following: initialListState, + reblogged_by: initialListState, + favourited_by: initialListState, + follow_requests: initialListState, + blocks: initialListState, + mutes: initialListState, +}); + +const normalizeList = (state, path, accounts, next) => { + return state.setIn(path, ImmutableMap({ + next, + items: ImmutableList(accounts.map(item => item.id)), + isLoading: false, + })); +}; + +const appendToList = (state, path, accounts, next) => { + return state.updateIn(path, map => { + return map.set('next', next).set('isLoading', false).update('items', list => list.concat(accounts.map(item => item.id))); + }); +}; + +const normalizeFollowRequest = (state, notification) => { + return state.updateIn(['follow_requests', 'items'], list => { + return list.filterNot(item => item === notification.account.id).unshift(notification.account.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 FOLLOWERS_FETCH_REQUEST: + case FOLLOWERS_EXPAND_REQUEST: + return state.setIn(['followers', action.id, 'isLoading'], true); + case FOLLOWERS_FETCH_FAIL: + case FOLLOWERS_EXPAND_FAIL: + return state.setIn(['followers', action.id, 'isLoading'], false); + 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 FOLLOWING_FETCH_REQUEST: + case FOLLOWING_EXPAND_REQUEST: + return state.setIn(['following', action.id, 'isLoading'], true); + case FOLLOWING_FETCH_FAIL: + case FOLLOWING_EXPAND_FAIL: + return state.setIn(['following', action.id, 'isLoading'], false); + 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 NOTIFICATIONS_UPDATE: + return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return normalizeList(state, ['follow_requests'], action.accounts, action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return appendToList(state, ['follow_requests'], action.accounts, action.next); + case FOLLOW_REQUESTS_FETCH_REQUEST: + case FOLLOW_REQUESTS_EXPAND_REQUEST: + return state.setIn(['follow_requests', 'isLoading'], true); + case FOLLOW_REQUESTS_FETCH_FAIL: + case FOLLOW_REQUESTS_EXPAND_FAIL: + return state.setIn(['follow_requests', 'isLoading'], false); + 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 normalizeList(state, ['blocks'], action.accounts, action.next); + case BLOCKS_EXPAND_SUCCESS: + return appendToList(state, ['blocks'], action.accounts, action.next); + case BLOCKS_FETCH_REQUEST: + case BLOCKS_EXPAND_REQUEST: + return state.setIn(['blocks', 'isLoading'], true); + case BLOCKS_FETCH_FAIL: + case BLOCKS_EXPAND_FAIL: + return state.setIn(['blocks', 'isLoading'], false); + case MUTES_FETCH_SUCCESS: + return normalizeList(state, ['mutes'], action.accounts, action.next); + case MUTES_EXPAND_SUCCESS: + return appendToList(state, ['mutes'], action.accounts, action.next); + case MUTES_FETCH_REQUEST: + case MUTES_EXPAND_REQUEST: + return state.setIn(['mutes', 'isLoading'], true); + case MUTES_FETCH_FAIL: + case MUTES_EXPAND_FAIL: + return state.setIn(['mutes', 'isLoading'], false); + case DIRECTORY_FETCH_SUCCESS: + return normalizeList(state, ['directory'], action.accounts, action.next); + case DIRECTORY_EXPAND_SUCCESS: + return appendToList(state, ['directory'], action.accounts, action.next); + case DIRECTORY_FETCH_REQUEST: + case DIRECTORY_EXPAND_REQUEST: + return state.setIn(['directory', 'isLoading'], true); + case DIRECTORY_FETCH_FAIL: + case DIRECTORY_EXPAND_FAIL: + return state.setIn(['directory', 'isLoading'], false); + 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..bb9180d12 --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -0,0 +1,196 @@ +import escapeTextContentForBrowser from 'escape-html'; +import { createSelector } from 'reselect'; +import { List as ImmutableList, is } 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); + }); + }); +}; + +export const toServerSideType = columnType => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + case 'account': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; + +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'); +}; + +// Memoize the filter regexps for each valid server contextType +const makeGetFiltersRegex = () => { + let memo = {}; + + return (state, { contextType }) => { + if (!contextType) return ImmutableList(); + + const serverSideType = toServerSideType(contextType); + const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); + + if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) { + const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); + const regex = regexFromFilters(filters); + memo[serverSideType] = { filters: filters, results: [dropRegex, regex] }; + } + return memo[serverSideType].results; + }; +}; + +export const getFiltersRegex = makeGetFiltersRegex(); + +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'])]), + (state, _) => state.getIn(['local_settings', 'filtering_behavior']), + (state, _) => state.get('filters', ImmutableList()), + (_, { contextType }) => contextType, + getFiltersRegex, + ], + + (statusBase, statusReblog, accountBase, accountReblog, filteringBehavior, filters, contextType, filtersRegex) => { + if (!statusBase) { + return null; + } + + const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0]; + + if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) { + return null; + } + + const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1]; + 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')); + + if (filtered && filteringBehavior === 'drop') { + return null; + } else if (filtered && filteringBehavior === 'content_warning') { + let spoilerText = (statusReblog || statusBase).get('spoiler_text', ''); + const searchIndex = (statusReblog || statusBase).get('search_index'); + const serverSideType = toServerSideType(contextType); + const enabledFilters = filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray(); + const matchingFilters = enabledFilters.filter(filter => { + const regexp = regexFromFilters([filter]); + return regexp.test(searchIndex) && !regexp.test(spoilerText); + }); + if (statusReblog) { + statusReblog = statusReblog.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', ')); + statusReblog = statusReblog.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', ')); + } else { + statusBase = statusBase.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', ')); + statusBase = statusBase.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', ')); + } + } + + 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'), + message_values: item.get('message_values'), + 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'), + (state, id) => state.getIn(['accounts', id]), +], (statusIds, statuses, account) => { + let medias = ImmutableList(); + + statusIds.forEach(statusId => { + const status = statuses.get(statusId); + medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status).set('account', account))); + }); + + 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..e18af842f --- /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..c92bc8608 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/_mixins.scss @@ -0,0 +1,96 @@ +@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: 0; + box-shadow: none; + font-family: inherit; + background: $ui-base-color; + color: $darker-text-color; + font-size: 14px; + margin: 0; +} + +@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..2cc43afec --- /dev/null +++ b/app/javascript/flavours/glitch/styles/about.scss @@ -0,0 +1,908 @@ +$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: 14px; + font-weight: 400; + line-height: 1.7; + word-wrap: break-word; + color: $darker-text-color; + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + p, + li { + color: $darker-text-color; + } + + p { + margin-top: 0; + margin-bottom: .85em; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: 700; + color: $secondary-text-color; + } + + em { + font-style: italic; + color: $secondary-text-color; + } + + code { + font-size: 0.85em; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding: 0.2em 0.3em; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: $font-display, sans-serif; + margin-top: 1.275em; + margin-bottom: .85em; + font-weight: 500; + color: $secondary-text-color; + } + + h1 { + font-size: 2em; + } + + h2 { + font-size: 1.75em; + } + + h3 { + font-size: 1.5em; + } + + h4 { + font-size: 1.25em; + } + + h5, + h6 { + font-size: 1em; + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + ul, + ol { + margin: 0; + padding: 0; + padding-left: 2em; + margin-bottom: 0.85em; + + &[type='a'] { + list-style-type: lower-alpha; + } + + &[type='i'] { + list-style-type: lower-roman; + } + } + + hr { + width: 100%; + height: 0; + border: 0; + border-bottom: 1px solid lighten($ui-base-color, 4%); + margin: 1.7em 0; + + &.spacer { + height: 1px; + border: 0; + } + } + + table { + width: 100%; + border-collapse: collapse; + break-inside: auto; + margin-top: 24px; + margin-bottom: 32px; + + thead tr, + tbody tr { + border-bottom: 1px solid lighten($ui-base-color, 4%); + font-size: 1em; + line-height: 1.625; + font-weight: 400; + text-align: left; + color: $darker-text-color; + } + + thead tr { + border-bottom-width: 2px; + line-height: 1.5; + font-weight: 500; + color: $dark-text-color; + } + + th, + td { + padding: 8px; + align-self: start; + align-items: start; + word-break: break-all; + + &.nowrap { + width: 25%; + position: relative; + + &::before { + content: ' '; + visibility: hidden; + } + + span { + position: absolute; + left: 8px; + right: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + } + + & > :first-child { + margin-top: 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; + 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: $ui-base-color; + 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; + } + + .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; + + svg { + fill: $primary-text-color; + 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%; + overflow-x: hidden; + } + } + + .account { + padding: 10px 0; + border-bottom: 0; + + .account__display-name { + display: flex; + align-items: center; + } + } + + &__counters__wrapper { + display: flex; + } + + &__counter { + padding: 10px; + width: 50%; + + 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; + } +} + +.rules-list { + background: darken($ui-base-color, 2%); + border: 1px solid darken($ui-base-color, 8%); + border-radius: 4px; + padding: 0.5em 2.5em !important; + margin-top: 1.85em !important; + + li { + border-bottom: 1px solid lighten($ui-base-color, 4%); + color: $dark-text-color; + padding: 1em; + + &:last-child { + border-bottom: 0; + } + } + + &__text { + color: $primary-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..cb27497a4 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/accessibility.scss @@ -0,0 +1,35 @@ +$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default; + +%emoji-color-inversion { + filter: invert(1); +} + +.emojione { + @each $emoji in $emojis-requiring-inversion { + &[title=':#{$emoji}:'] { + @extend %emoji-color-inversion; + } + } +} + +.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..a5ddde937 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/accounts.scss @@ -0,0 +1,329 @@ +.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%); + object-fit: cover; + } + } + + .display-name { + margin-left: 15px; + text-align: left; + + i[data-hidden] { + display: none; + } + + 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, +.simple_form .recommended { + 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 { + max-width: 100vw; + 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..4801a4644 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -0,0 +1,934 @@ +$no-columns-breakpoint: 600px; +$sidebar-width: 240px; +$content-width: 840px; + +.admin-wrapper { + display: flex; + justify-content: center; + width: 100%; + min-height: 100vh; + + .sidebar-wrapper { + min-height: 100vh; + overflow: hidden; + pointer-events: none; + flex: 1 1 auto; + + &__inner { + display: flex; + justify-content: flex-end; + background: $ui-base-color; + height: 100%; + } + } + + .sidebar { + width: $sidebar-width; + padding: 0; + pointer-events: auto; + + &__toggle { + display: none; + background: lighten($ui-base-color, 8%); + height: 48px; + + &__logo { + flex: 1 1 auto; + + a { + display: inline-block; + padding: 15px; + } + + svg { + fill: $primary-text-color; + height: 20px; + position: relative; + bottom: -2px; + } + } + + &__icon { + display: block; + color: $darker-text-color; + text-decoration: none; + flex: 0 0 auto; + font-size: 20px; + padding: 15px; + } + + a { + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 12%); + } + } + } + + .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; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + 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 { + box-sizing: border-box; + width: 100%; + max-width: $content-width; + flex: 1 1 auto; + } + + @media screen and (max-width: $content-width + $sidebar-width) { + .sidebar-wrapper--empty { + display: none; + } + + .sidebar-wrapper { + width: $sidebar-width; + flex: 0 0 auto; + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + .sidebar-wrapper { + width: 100%; + } + } + + .content { + padding: 55px 15px 20px 25px; + + @media screen and (max-width: $no-columns-breakpoint) { + max-width: none; + padding: 15px; + padding-top: 30px; + } + + &-heading { + display: flex; + + padding-bottom: 36px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + margin: -15px -15px 40px 0; + + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + + & > * { + margin-top: 15px; + margin-right: 15px; + } + + &-actions { + display: inline-flex; + + & > :not(:first-child) { + margin-left: 5px; + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + border-bottom: 0; + padding-bottom: 0; + } + } + + h2 { + color: $secondary-text-color; + font-size: 24px; + line-height: 36px; + font-weight: 400; + + @media screen and (max-width: $no-columns-breakpoint) { + font-weight: 700; + } + } + + 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: 500; + } + + .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: 0; + } + + & > p { + font-size: 14px; + line-height: 21px; + 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; + } + } + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + + .sidebar-wrapper { + min-height: 0; + } + + .sidebar { + width: 100%; + padding: 0; + height: auto; + + &__toggle { + display: flex; + } + + & > ul { + display: none; + } + + ul a, + ul ul a { + border-radius: 0; + border-bottom: 1px solid lighten($ui-base-color, 4%); + transition: none; + + &:hover { + transition: none; + } + } + + ul ul { + border-radius: 0; + } + + ul .simple-navigation-active-leaf a { + border-bottom-color: $ui-highlight-color; + } + } + } +} + +hr.spacer { + width: 100%; + border: 0; + margin: 20px 0; + height: 1px; +} + +body, +.admin-wrapper .content { + .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; + } + + .warning-hint { + color: $gold-star; + font-weight: 500; + } +} + +.filters { + display: flex; + flex-wrap: wrap; + + .filter-subset { + flex: 0 0 auto; + margin: 0 40px 20px 0; + + &:last-child { + margin-bottom: 30px; + } + + 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; + } + } + } + + &--with-select strong { + display: block; + margin-bottom: 10px; + } + + 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%; +} + +.simple_form { + .actions { + margin-top: 15px; + } + + .button { + font-size: 15px; + } +} + +.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; + } + } +} + +.special-action-button, +.back-link { + text-align: right; + flex: 1 1 auto; +} + +.action-buttons { + display: flex; + overflow: hidden; + justify-content: space-between; +} + +.spacer { + flex: 1 1 auto; +} + +.log-entry { + line-height: 20px; + padding: 15px 0; + background: $ui-base-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &:last-child { + border-bottom: 0; + } + + &__header { + display: flex; + justify-content: flex-start; + align-items: center; + color: $darker-text-color; + font-size: 14px; + padding: 0 10px; + } + + &__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; + } + + a, + .username, + .target { + color: $secondary-text-color; + text-decoration: none; + font-weight: 500; + } +} + +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; +} + +.admin-account-bio { + display: flex; + flex-wrap: wrap; + margin: 0 -5px; + margin-top: 20px; + + > div { + box-sizing: border-box; + padding: 0 5px; + margin-bottom: 10px; + flex: 1 0 50%; + } + + .account__header__fields, + .account__header__content { + background: lighten($ui-base-color, 8%); + border-radius: 4px; + height: 100%; + } + + .account__header__fields { + margin: 0; + border: 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 { + box-sizing: border-box; + padding: 20px; + color: $primary-text-color; + } +} + +.center-text { + text-align: center; +} + +.announcements-list { + border: 1px solid lighten($ui-base-color, 4%); + border-radius: 4px; + + &__item { + padding: 15px 0; + background: $ui-base-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__title { + padding: 0 15px; + display: block; + font-weight: 500; + font-size: 18px; + line-height: 1.5; + color: $secondary-text-color; + text-decoration: none; + margin-bottom: 10px; + + &:hover, + &:focus, + &:active { + color: $primary-text-color; + } + } + + &__meta { + padding: 0 15px; + color: $dark-text-color; + } + + &__action-bar { + display: flex; + justify-content: space-between; + align-items: center; + } + + &:last-child { + border-bottom: 0; + } + } +} + +.account-badges { + margin: -2px 0; +} + +.dashboard__counters.admin-account-counters { + margin-top: 10px; +} diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss new file mode 100644 index 000000000..be0e1b860 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/basics.scss @@ -0,0 +1,197 @@ +@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, 7%); + 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 { + padding: 0; + + &.layout-single-column { + height: auto; + min-height: 100vh; + overflow-y: scroll; + } + + &.layout-multiple-columns { + position: absolute; + width: 100%; + height: 100%; + } + + &.with-modals--active { + overflow-y: hidden; + } + } + + &.lighter { + background: $ui-base-color; + } + + &.with-modals { + overflow-x: hidden; + overflow-y: scroll; + + &--active { + overflow-y: hidden; + } + } + + &.player { + padding: 0; + margin: 0; + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + + & > div { + height: 100%; + } + + .video-player video { + width: 100%; + height: 100%; + max-height: 100vh; + } + + .media-gallery { + margin-top: 0; + height: 100% !important; + border-radius: 0; + } + + .media-gallery__item { + border-radius: 0; + } + } + + &.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%); + 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%; + align-items: center; + justify-content: center; + outline: 0 !important; + } +} + +.layout-single-column .app-holder { + &, + & > div { + min-height: 100vh; + } +} + +.layout-multiple-columns .app-holder { + &, + & > div { + height: 100%; + } +} + +.logo-resources { + display: none; +} + +// NoScript adds a __ns__pop2top class to the full ancestry of blocked elements, +// to set the z-index to a high value, which messes with modals and dropdowns. +// Blocked elements can in theory only be media and frames/embeds, so they +// should only appear in statuses, under divs and articles. +body, +div, +article { + .__ns__pop2top { + z-index: unset !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..377cdd91f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -0,0 +1,720 @@ +.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; + + &--with-note { + strong { + display: inline; + } + } + } + + &__note { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: $ui-secondary-color; + } + + &.small { + border: 0; + padding: 0; + + & > .account__avatar-wrapper { margin: 0 8px 0 0 } + + & > .display-name { + height: 24px; + line-height: 24px; + } + } +} + +.follow-recommendations-account { + .icon-button { + color: $ui-primary-color; + + &.active { + color: $valid-value-color; + } + } +} + +.account__wrapper { + display: flex; +} + +.account__avatar-wrapper { + float: left; + margin-left: 12px; + margin-right: 12px; +} + +.account__avatar { + @include avatar-radius(); + display: block; + position: relative; + cursor: pointer; + + width: 36px; + height: 36px; + background-size: 36px 36px; + + &-inline { + display: inline-block; + vertical-align: middle; + margin-right: 5px; + } + + &-composite { + @include avatar-radius; + overflow: hidden; + position: relative; + + & div { + @include avatar-radius; + float: left; + position: relative; + box-sizing: border-box; + } + + &__label { + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: $primary-text-color; + text-shadow: 1px 1px 2px $base-shadow-color; + font-weight: 700; + font-size: 15px; + } + } +} + +.account__avatar-overlay { + position: relative; + @include avatar-size(48px); + + &-base { + @include avatar-radius(); + @include avatar-size(36px); + + img { + @include avatar-radius; + width: 100%; + height: 100%; + } + } + + &-overlay { + @include avatar-radius(); + @include avatar-size(24px); + + position: absolute; + bottom: 0; + right: 0; + z-index: 1; + + img { + @include avatar-radius; + width: 100%; + height: 100%; + } + } +} + +.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; +} + +.relationship-tag { + color: $primary-text-color; + margin-bottom: 4px; + display: block; + 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: 0; + 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; + } + } + } + + &.directory__section-headline { + background: darken($ui-base-color, 2%); + border-bottom-color: transparent; + + a, + button { + &.active { + &::before { + display: none; + } + + &::after { + border-color: transparent transparent darken($ui-base-color, 7%); + } + } + } + } +} + +.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 10px; + 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; + } + + & > .icon-button { + margin-right: 8px; + } + + .button { + margin: 0 8px; + } + } + + &__name { + padding: 5px 10px; + + .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__joined { + font-size: 14px; + padding: 5px 15px; + color: $darker-text-color; + + .columns-area--mobile & { + padding-left: 20px; + padding-right: 20px; + } + } + + .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; + padding: 10px 0; + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + padding: 5px 10px; + font-weight: 500; + + strong { + font-weight: 700; + color: $primary-text-color; + } + } + } + } + + &__account-note { + margin: 0 -5px; + padding: 10px 15px; + display: flex; + flex-direction: column; + font-size: 14px; + font-weight: 400; + border-top: 1px solid lighten($ui-base-color, 12%); + border-bottom: 1px solid lighten($ui-base-color, 12%); + + &__header { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 5px; + color: $darker-text-color; + } + + &__content { + white-space: pre-wrap; + padding: 10px 0; + } + + &__buttons { + display: flex; + flex-direction: row; + justify-content: flex-end; + flex: 1 0; + + .icon-button { + font-size: 14px; + padding: 2px 6px; + color: $darker-text-color; + + &:hover, + &:active, + &:focus { + color: lighten($darker-text-color, 7%); + background-color: rgba($darker-text-color, 0.15); + } + + &:focus { + background-color: rgba($darker-text-color, 0.3); + } + + &[disabled] { + color: darken($darker-text-color, 13%); + background-color: transparent; + cursor: default; + } + } + + .flex-spacer { + flex: 0 0 5px; + background: transparent; + } + } + + strong { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + } + + textarea { + display: block; + box-sizing: border-box; + width: calc(100% + 20px); + margin: 0; + margin-top: 5px; + color: $secondary-text-color; + background: $ui-base-color; + padding: 10px; + margin: 0 -10px; + font-family: inherit; + font-size: 14px; + resize: none; + border: 0; + outline: 0; + border-radius: 4px; + + &::placeholder { + color: $dark-text-color; + opacity: 1; + } + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/announcements.scss b/app/javascript/flavours/glitch/styles/components/announcements.scss new file mode 100644 index 000000000..52feefd3c --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/announcements.scss @@ -0,0 +1,229 @@ +.announcements__item__content { + word-wrap: break-word; + overflow-y: auto; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p { + margin-bottom: 10px; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + &.unhandled-link { + color: lighten($ui-highlight-color, 8%); + } + } +} + +.announcements { + background: lighten($ui-base-color, 8%); + font-size: 13px; + display: flex; + align-items: flex-end; + + &__mastodon { + width: 124px; + flex: 0 0 auto; + + @media screen and (max-width: 124px + 300px) { + display: none; + } + } + + &__container { + width: calc(100% - 124px); + flex: 0 0 auto; + position: relative; + + @media screen and (max-width: 124px + 300px) { + width: 100%; + } + } + + &__item { + box-sizing: border-box; + width: 100%; + padding: 15px; + position: relative; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + max-height: 50vh; + overflow: hidden; + display: flex; + flex-direction: column; + + &__range { + display: block; + font-weight: 500; + margin-bottom: 10px; + padding-right: 18px; + } + + &__unread { + position: absolute; + top: 19px; + right: 19px; + display: block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } + } + + &__pagination { + padding: 15px; + color: $darker-text-color; + position: absolute; + bottom: 3px; + right: 0; + } +} + +.layout-multiple-columns .announcements__mastodon { + display: none; +} + +.layout-multiple-columns .announcements__container { + width: 100%; +} + +.reactions-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 15px; + margin-left: -2px; + width: calc(100% - (90px - 33px)); + + &__item { + flex-shrink: 0; + background: lighten($ui-base-color, 12%); + border: 0; + border-radius: 3px; + margin: 2px; + cursor: pointer; + user-select: none; + padding: 0 6px; + display: flex; + align-items: center; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &__emoji { + display: block; + margin: 3px 0; + width: 16px; + height: 16px; + + img { + display: block; + margin: 0; + width: 100%; + height: 100%; + min-width: auto; + min-height: auto; + vertical-align: bottom; + object-fit: contain; + } + } + + &__count { + display: block; + min-width: 9px; + font-size: 13px; + font-weight: 500; + text-align: center; + margin-left: 6px; + color: $darker-text-color; + } + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 16%); + transition: all 200ms ease-out; + transition-property: background-color, color; + + &__count { + color: lighten($darker-text-color, 4%); + } + } + + &.active { + transition: all 100ms ease-in; + transition-property: background-color, color; + background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%); + + .reactions-bar__item__count { + color: lighten($highlight-text-color, 8%); + } + } + } + + .emoji-picker-dropdown { + margin: 2px; + } + + &:hover .emoji-button { + opacity: 0.85; + } + + .emoji-button { + color: $darker-text-color; + margin: 0; + font-size: 16px; + width: auto; + flex-shrink: 0; + padding: 0 6px; + height: 22px; + display: flex; + align-items: center; + opacity: 0.5; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + opacity: 1; + color: lighten($darker-text-color, 4%); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + } + + &--empty { + .emoji-button { + padding: 0; + } + } +} 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..fb1451cb2 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/boost.scss @@ -0,0 +1,32 @@ +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 i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($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>"); + } + + &.reblogPrivate { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color($action-button-color)}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>"); + } + + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 15.980703 3.0497656 15.339844 7.2597656 15.339844 L 11.869141 15.339844 L 11.869141 14.119141 L 11.869141 13.523438 L 11.869141 12.441406 C 11.869141 12.441406 11.869141 12.439453 11.869141 12.439453 L 7.2695312 12.439453 C 6.8295312 12.439453 6.5507814 12.140703 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 z M 17.150391 3.5800781 L 17.130859 3.5898438 C 16.580859 3.5698436 15.810469 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 14.699219 6.5195312 C 15.106887 6.5195312 15.397113 6.7872181 15.414062 7.2050781 C 15.738375 7.0991315 16.077769 7.0273437 16.435547 7.0273438 L 16.578125 7.0273438 C 17.24903 7.0273438 17.874081 7.2325787 18.400391 7.578125 L 18.400391 7.2402344 C 18.400391 4.0902344 18.800391 3.6200781 17.150391 3.5800781 z M 16.435547 8.0273438 C 15.143818 8.0273438 14.083984 9.0851838 14.083984 10.376953 L 14.083984 11.607422 L 13.570312 11.607422 C 13.375448 11.607422 13.210603 11.704118 13.119141 11.791016 C 13.027691 11.877916 12.983569 11.958238 12.951172 12.03125 C 12.886382 12.177277 12.867187 12.304789 12.867188 12.441406 L 12.867188 13.523438 L 12.867188 14.119141 L 12.867188 15.677734 L 12.867188 16.509766 L 13.570312 16.509766 L 19.472656 16.509766 L 20.173828 16.509766 L 20.173828 15.677734 L 20.173828 13.523438 L 20.173828 12.441406 C 20.173828 12.304794 20.156597 12.177281 20.091797 12.03125 C 20.059397 11.95824 20.015299 11.877916 19.923828 11.791016 C 19.832368 11.704116 19.667509 11.607422 19.472656 11.607422 L 18.927734 11.607422 L 18.927734 10.376953 C 18.927734 9.0851838 17.867902 8.0273438 16.576172 8.0273438 L 16.435547 8.0273438 z M 16.435547 9.2207031 L 16.576172 9.2207031 C 17.22782 9.2207031 17.734375 9.7251013 17.734375 10.376953 L 17.734375 11.607422 L 15.277344 11.607422 L 15.277344 10.376953 C 15.277344 9.7251013 15.7839 9.2207031 16.435547 9.2207031 z M 12.919922 9.9394531 C 12.559922 9.9594531 12.359141 10.480234 12.619141 10.740234 L 12.751953 10.904297 C 12.862211 10.870135 12.980058 10.842244 13.085938 10.802734 L 13.085938 10.378906 C 13.085938 10.228632 13.111295 10.084741 13.130859 9.9394531 L 12.919922 9.9394531 z M 19.882812 9.9394531 C 19.902378 10.084741 19.927734 10.228632 19.927734 10.378906 L 19.927734 10.791016 C 20.168811 10.875098 20.455966 10.916935 20.613281 11.066406 C 20.691227 11.140457 20.749315 11.223053 20.806641 11.302734 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 19.882812 9.9394531 z M 16.435547 10.220703 C 16.301234 10.220703 16.277344 10.244432 16.277344 10.378906 L 16.277344 10.607422 L 16.734375 10.607422 L 16.734375 10.378906 C 16.734375 10.244433 16.712442 10.220703 16.578125 10.220703 L 16.435547 10.220703 z ' fill='#{hex-color(lighten($action-button-color, 7%))}' stroke-width='0'/><path d='M 7.7792969 19.650391 L 7.7792969 19.660156 C 7.5392969 19.680156 7.3398437 19.910156 7.3398438 20.160156 L 7.3398438 22.619141 L 7.2792969 22.619141 C 6.1992969 22.619141 5.4208594 22.589844 4.8808594 22.589844 C 3.2408594 22.589844 3.6308594 23.020234 3.6308594 26.240234 L 3.6308594 30.710938 C 3.6308594 34.970937 3.0692969 34.330078 7.2792969 34.330078 L 8.5 34.330078 L 7.1992188 33.269531 C 7.0992188 33.189531 7.02 33.070703 7 32.970703 C 6.98 32.800703 7.0592186 32.619531 7.1992188 32.519531 L 8.5292969 31.419922 L 7.2792969 31.419922 C 6.8392969 31.419922 6.5605469 31.120703 6.5605469 30.720703 L 6.5605469 26.240234 C 6.5605469 25.800234 6.8392969 25.519531 7.2792969 25.519531 L 7.3398438 25.519531 L 7.3398438 28.019531 C 7.3398438 28.399531 7.8801564 28.650391 8.1601562 28.400391 L 13.060547 24.470703 C 13.310547 24.290703 13.310547 23.869453 13.060547 23.689453 L 8.1601562 19.769531 C 8.0601563 19.669531 7.9192969 19.630391 7.7792969 19.650391 z M 17.119141 22.580078 L 17.119141 22.589844 C 16.579141 22.569844 15.820703 22.609375 14.720703 22.609375 L 13.470703 22.609375 L 14.769531 23.679688 C 14.869531 23.749688 14.950703 23.879766 14.970703 24.009766 C 14.990703 24.169766 14.909531 24.310156 14.769531 24.410156 L 13.439453 25.509766 L 14.720703 25.509766 C 15.129702 25.509766 15.41841 25.778986 15.433594 26.199219 C 15.752266 26.097283 16.084896 26.027344 16.435547 26.027344 L 16.578125 26.027344 C 17.236645 26.027344 17.848901 26.228565 18.369141 26.5625 L 18.369141 26.240234 C 18.369141 23.090234 18.769141 22.620078 17.119141 22.580078 z M 16.435547 27.027344 C 15.143818 27.027344 14.083984 28.085184 14.083984 29.376953 L 14.083984 30.607422 L 13.570312 30.607422 C 13.375452 30.607422 13.210603 30.704118 13.119141 30.791016 C 13.027691 30.877916 12.983569 30.958238 12.951172 31.03125 C 12.886382 31.177277 12.867184 31.304789 12.867188 31.441406 L 12.867188 32.523438 L 12.867188 33.119141 L 12.867188 34.677734 L 12.867188 35.509766 L 13.570312 35.509766 L 19.472656 35.509766 L 20.173828 35.509766 L 20.173828 34.677734 L 20.173828 32.523438 L 20.173828 31.441406 C 20.173828 31.304794 20.156597 31.177281 20.091797 31.03125 C 20.059397 30.95824 20.015299 30.877916 19.923828 30.791016 C 19.832368 30.704116 19.667509 30.607422 19.472656 30.607422 L 18.927734 30.607422 L 18.927734 29.376953 C 18.927734 28.085184 17.867902 27.027344 16.576172 27.027344 L 16.435547 27.027344 z M 16.435547 28.220703 L 16.576172 28.220703 C 17.22782 28.220703 17.734375 28.725101 17.734375 29.376953 L 17.734375 30.607422 L 15.277344 30.607422 L 15.277344 29.376953 C 15.277344 28.725101 15.7839 28.220703 16.435547 28.220703 z M 13.109375 29.150391 L 8.9199219 32.509766 C 8.6599219 32.689766 8.6599219 33.109063 8.9199219 33.289062 L 11.869141 35.648438 L 11.869141 34.677734 L 11.869141 33.119141 L 11.869141 32.523438 L 11.869141 31.441406 C 11.869141 31.217489 11.912641 30.907486 12.037109 30.626953 C 12.093758 30.499284 12.228597 30.257492 12.429688 30.066406 C 12.580253 29.92335 12.859197 29.887344 13.085938 29.802734 L 13.085938 29.378906 C 13.085938 29.300761 13.104 29.227272 13.109375 29.150391 z M 16.435547 29.220703 C 16.301234 29.220703 16.277344 29.244432 16.277344 29.378906 L 16.277344 29.607422 L 16.734375 29.607422 L 16.734375 29.378906 C 16.734375 29.244433 16.712442 29.220703 16.578125 29.220703 L 16.435547 29.220703 z M 12.943359 36.509766 L 13.820312 37.210938 C 14.090314 37.460938 14.639141 37.210078 14.619141 36.830078 L 14.619141 36.509766 L 13.570312 36.509766 L 12.943359 36.509766 z M 10.330078 38.650391 L 10.339844 38.660156 C 10.099844 38.680156 9.9001562 38.910156 9.9101562 39.160156 L 9.9101562 41.630859 L 7.3007812 41.630859 C 6.2207812 41.630859 5.4403906 41.589844 4.9003906 41.589844 C 3.2603906 41.589844 3.6503906 42.020234 3.6503906 45.240234 L 3.6503906 49.710938 C 3.6503906 53.370936 3.4202344 53.409141 5.9902344 53.369141 L 4.6503906 52.269531 C 4.5503906 52.189531 4.4692187 52.070703 4.4492188 51.970703 C 4.4492188 51.800703 4.5203906 51.619531 4.6503906 51.519531 L 6.609375 49.919922 C 6.579375 49.859922 6.5703125 49.790703 6.5703125 49.720703 L 6.5703125 45.240234 C 6.5703125 44.800234 6.8490625 44.519531 7.2890625 44.519531 L 9.9003906 44.519531 L 9.9003906 47.019531 C 9.9003906 47.379531 10.399219 47.620391 10.699219 47.400391 L 15.630859 43.470703 C 15.870859 43.290703 15.870859 42.869453 15.630859 42.689453 L 10.689453 38.769531 C 10.589453 38.689531 10.460078 38.640391 10.330078 38.650391 z M 16.869141 41.585938 C 16.616211 41.581522 16.322969 41.584844 15.980469 41.589844 L 15.970703 41.589844 L 17.310547 42.689453 C 17.410547 42.759453 17.489766 42.889531 17.509766 43.019531 C 17.529766 43.179531 17.479609 43.319922 17.349609 43.419922 L 15.390625 45.019531 C 15.406724 45.075878 15.427133 45.132837 15.4375 45.197266 C 15.754974 45.096169 16.086404 45.027344 16.435547 45.027344 L 16.578125 45.027344 C 17.24129 45.027344 17.858323 45.230088 18.380859 45.568359 L 18.380859 45.25 C 18.380859 42.0475 18.639648 41.616836 16.869141 41.585938 z M 16.435547 46.027344 C 15.143818 46.027344 14.083984 47.085184 14.083984 48.376953 L 14.083984 49.607422 L 13.570312 49.607422 C 13.375448 49.607422 13.210603 49.704118 13.119141 49.791016 C 13.027691 49.877916 12.983569 49.958238 12.951172 50.03125 C 12.886382 50.177277 12.867187 50.304789 12.867188 50.441406 L 12.867188 51.523438 L 12.867188 52.119141 L 12.867188 53.677734 L 12.867188 54.509766 L 13.570312 54.509766 L 19.472656 54.509766 L 20.173828 54.509766 L 20.173828 53.677734 L 20.173828 51.523438 L 20.173828 50.441406 C 20.173828 50.304794 20.156597 50.177281 20.091797 50.03125 C 20.059397 49.95824 20.015299 49.877916 19.923828 49.791016 C 19.832368 49.704116 19.667509 49.607422 19.472656 49.607422 L 18.927734 49.607422 L 18.927734 48.376953 C 18.927734 47.085184 17.867902 46.027344 16.576172 46.027344 L 16.435547 46.027344 z M 16.435547 47.220703 L 16.576172 47.220703 C 17.22782 47.220703 17.734375 47.725101 17.734375 48.376953 L 17.734375 49.607422 L 15.277344 49.607422 L 15.277344 48.376953 C 15.277344 47.725101 15.7839 47.220703 16.435547 47.220703 z M 11.470703 47.490234 C 11.410703 47.510234 11.349063 47.539844 11.289062 47.589844 L 6.3496094 51.519531 C 6.1096094 51.699531 6.1096094 52.120781 6.3496094 52.300781 L 11.289062 56.220703 C 11.569064 56.440703 12.070312 56.199844 12.070312 55.839844 L 12.070312 55.509766 L 11.869141 55.509766 L 11.869141 53.677734 L 11.869141 52.119141 L 11.869141 51.523438 L 11.869141 50.441406 C 11.869141 50.217489 11.912641 49.907486 12.037109 49.626953 C 12.043809 49.611855 12.061451 49.584424 12.070312 49.566406 L 12.070312 47.960938 C 12.070312 47.660938 11.770703 47.430234 11.470703 47.490234 z M 16.435547 48.220703 C 16.301234 48.220703 16.277344 48.244432 16.277344 48.378906 L 16.277344 48.607422 L 16.734375 48.607422 L 16.734375 48.378906 C 16.734375 48.244433 16.712442 48.220703 16.578125 48.220703 L 16.435547 48.220703 z M 13.060547 57.650391 L 13.060547 57.660156 C 12.830547 57.690156 12.660156 57.920156 12.660156 58.160156 L 12.660156 60.630859 L 7.2792969 60.630859 C 6.1992969 60.630859 5.4208594 60.589844 4.8808594 60.589844 C 3.2408594 60.589844 3.6308594 61.020234 3.6308594 64.240234 L 3.6308594 69.109375 L 6.5605469 66.740234 L 6.5605469 64.240234 C 6.5605469 63.800234 6.8392969 63.519531 7.2792969 63.519531 L 12.660156 63.519531 L 12.660156 66.019531 C 12.660156 66.299799 12.960394 66.500006 13.226562 66.474609 C 13.625751 65.076914 14.904956 64.035678 16.421875 64.029297 L 18.380859 62.470703 C 18.620859 62.290703 18.620859 61.869453 18.380859 61.689453 L 13.439453 57.769531 C 13.339453 57.669531 13.200547 57.630391 13.060547 57.650391 z M 18.359375 63.810547 L 17.800781 64.269531 C 18.004793 64.350836 18.198411 64.450249 18.380859 64.568359 L 18.380859 64.25 L 18.380859 63.810547 L 18.359375 63.810547 z M 16.435547 65.027344 C 15.143818 65.027344 14.083984 66.085184 14.083984 67.376953 L 14.083984 68.607422 L 13.570312 68.607422 C 13.375448 68.607422 13.210603 68.704118 13.119141 68.791016 C 13.027691 68.877916 12.983569 68.958238 12.951172 69.03125 C 12.886382 69.177277 12.867187 69.304789 12.867188 69.441406 L 12.867188 70.523438 L 12.867188 71.119141 L 12.867188 72.677734 L 12.867188 73.509766 L 13.570312 73.509766 L 19.472656 73.509766 L 20.173828 73.509766 L 20.173828 72.677734 L 20.173828 70.523438 L 20.173828 69.441406 C 20.173828 69.304794 20.156597 69.177281 20.091797 69.03125 C 20.059397 68.95824 20.015299 68.877916 19.923828 68.791016 C 19.832368 68.704116 19.667509 68.607422 19.472656 68.607422 L 18.927734 68.607422 L 18.927734 67.376953 C 18.927734 66.085184 17.867902 65.027344 16.576172 65.027344 L 16.435547 65.027344 z M 16.435547 66.220703 L 16.576172 66.220703 C 17.22782 66.220703 17.734375 66.725101 17.734375 67.376953 L 17.734375 68.607422 L 15.277344 68.607422 L 15.277344 67.376953 C 15.277344 66.725101 15.7839 66.220703 16.435547 66.220703 z M 8.7207031 66.509766 C 8.6507031 66.529766 8.5895312 66.559375 8.5195312 66.609375 L 3.5996094 70.519531 C 3.3496094 70.699531 3.3496094 71.120781 3.5996094 71.300781 L 8.5292969 75.220703 C 8.8092969 75.440703 9.3105469 75.199844 9.3105469 74.839844 L 9.3105469 72.339844 L 11.869141 72.339844 L 11.869141 71.119141 L 11.869141 70.523438 L 11.869141 69.449219 L 9.3203125 69.449219 L 9.3203125 66.980469 C 9.3203125 66.680469 9.0007031 66.449766 8.7207031 66.509766 z M 16.435547 67.220703 C 16.301234 67.220703 16.277344 67.244432 16.277344 67.378906 L 16.277344 67.607422 L 16.734375 67.607422 L 16.734375 67.378906 C 16.734375 67.244433 16.712442 67.220703 16.578125 67.220703 L 16.435547 67.220703 z M 19.248047 78.800781 C 19.148558 78.831033 19.050295 78.90106 18.970703 78.970703 L 18.070312 79.869141 C 17.630312 79.569141 16.710703 79.619141 14.720703 79.619141 L 7.2792969 79.619141 C 6.1992969 79.619141 5.4208594 79.589844 4.8808594 79.589844 C 3.2408594 79.589844 3.6308594 80.020234 3.6308594 83.240234 L 3.6308594 83.939453 L 6.5605469 84.240234 L 6.5605469 83.240234 C 6.5605469 82.800234 6.8392969 82.519531 7.2792969 82.519531 L 14.720703 82.519531 C 14.920703 82.519531 15.090703 82.600703 15.220703 82.720703 L 13.419922 84.519531 C 13.279464 84.665607 13.281282 84.881022 13.363281 85.054688 C 13.880838 83.867655 15.067337 83.027344 16.435547 83.027344 L 16.578125 83.027344 C 18.290465 83.027344 19.703357 84.345788 19.890625 86.011719 L 19.960938 86.019531 C 20.240938 86.049531 20.520234 85.770234 20.490234 85.490234 L 19.789062 79.240234 C 19.789062 78.973661 19.498025 78.767523 19.25 78.800781 L 19.248047 78.800781 z M 16.435547 84.027344 C 15.143818 84.027344 14.083984 85.085184 14.083984 86.376953 L 14.083984 87.607422 L 13.570312 87.607422 C 13.375448 87.607422 13.210603 87.704118 13.119141 87.791016 C 13.027691 87.877916 12.983569 87.958238 12.951172 88.03125 C 12.886382 88.177277 12.867187 88.304789 12.867188 88.441406 L 12.867188 89.523438 L 12.867188 90.119141 L 12.867188 91.677734 L 12.867188 92.509766 L 13.570312 92.509766 L 19.472656 92.509766 L 20.173828 92.509766 L 20.173828 91.677734 L 20.173828 89.523438 L 20.173828 88.441406 C 20.173828 88.304794 20.156597 88.177281 20.091797 88.03125 C 20.059397 87.95824 20.015299 87.877916 19.923828 87.791016 C 19.832368 87.704116 19.667509 87.607422 19.472656 87.607422 L 18.927734 87.607422 L 18.927734 86.376953 C 18.927734 85.085184 17.867902 84.027344 16.576172 84.027344 L 16.435547 84.027344 z M 2.0507812 84.900391 C 1.8507824 84.970391 1.6907031 85.199453 1.7207031 85.439453 L 2.4199219 91.689453 C 2.4399219 92.049453 3 92.240929 3.25 91.960938 L 4.0507812 91.160156 C 4.0707812 91.160156 4.0898437 91.140156 4.0898438 91.160156 C 4.5498437 91.400156 5.4595313 91.330078 7.2695312 91.330078 L 11.869141 91.330078 L 11.869141 90.119141 L 11.869141 89.523438 L 11.869141 88.441406 C 11.869141 88.437991 11.871073 88.433136 11.871094 88.429688 L 7.2792969 88.429688 C 7.1292969 88.429688 6.9808594 88.400078 6.8808594 88.330078 L 8.8007812 86.400391 C 9.1007822 86.160391 8.8992969 85.600547 8.5292969 85.560547 L 2.25 84.910156 L 2.0507812 84.910156 L 2.0507812 84.900391 z M 16.435547 85.220703 L 16.576172 85.220703 C 17.22782 85.220703 17.734375 85.725101 17.734375 86.376953 L 17.734375 87.607422 L 15.277344 87.607422 L 15.277344 86.376953 C 15.277344 85.725101 15.7839 85.220703 16.435547 85.220703 z M 4.8808594 98.599609 C 3.5508594 98.599609 3.5400781 99.080402 3.5800781 100.90039 L 4.7207031 99.529297 C 4.8007031 99.429297 4.9405469 99.360078 5.0605469 99.330078 C 5.2205469 99.330078 5.4 99.409297 5.5 99.529297 L 7.1601562 101.56055 C 7.2001563 101.56055 7.2292969 101.5293 7.2792969 101.5293 L 14.720703 101.5293 C 15.060703 101.5293 15.289141 101.7293 15.369141 102.0293 L 12.939453 102.0293 C 12.599453 102.0793 12.410625 102.55055 12.640625 102.81055 L 13.470703 103.85742 C 14.029941 102.77899 15.146801 102.02734 16.435547 102.02734 L 16.578125 102.02734 C 18.158418 102.02734 19.491598 103.14879 19.835938 104.63086 L 21.279297 102.82031 C 21.499297 102.55031 21.260156 102.06078 20.910156 102.05078 L 18.400391 102.05078 C 18.420391 98.150792 19.000234 98.650391 14.740234 98.650391 L 7.2792969 98.650391 C 6.1992969 98.650391 5.4208594 98.609375 4.8808594 98.609375 L 4.8808594 98.599609 z M 5.0292969 101.06055 C 4.9292969 101.09055 4.83 101.15977 4.75 101.25977 L 0.81054688 106.16016 C 0.61054688 106.44016 0.8409375 106.92945 1.2109375 106.93945 L 3.5996094 106.93945 C 3.5796094 110.87945 3.1497656 110.33984 7.2597656 110.33984 L 11.869141 110.33984 L 11.869141 109.11914 L 11.869141 108.52344 L 11.869141 107.44141 L 11.869141 107.43945 L 7.2792969 107.43945 C 6.9292969 107.43945 6.7091406 107.23945 6.6191406 106.93945 L 9.0605469 106.93945 C 9.4305469 106.93945 9.6909375 106.44016 9.4609375 106.16016 L 5.5 101.25977 C 5.4 101.10977 5.1992969 101.03055 5.0292969 101.06055 z M 16.435547 103.02734 C 15.143818 103.02734 14.083984 104.08518 14.083984 105.37695 L 14.083984 106.60742 L 13.570312 106.60742 C 13.375448 106.60742 13.210603 106.70409 13.119141 106.79102 C 13.027691 106.87792 12.983569 106.95823 12.951172 107.03125 C 12.886382 107.17727 12.867187 107.30479 12.867188 107.44141 L 12.867188 108.52344 L 12.867188 109.11914 L 12.867188 110.67773 L 12.867188 111.50977 L 13.570312 111.50977 L 19.472656 111.50977 L 20.173828 111.50977 L 20.173828 110.67773 L 20.173828 108.52344 L 20.173828 107.44141 C 20.173828 107.3048 20.156597 107.17728 20.091797 107.03125 C 20.059397 106.95825 20.015299 106.87792 19.923828 106.79102 C 19.832368 106.70412 19.667509 106.60742 19.472656 106.60742 L 18.927734 106.60742 L 18.927734 105.37695 C 18.927734 104.08518 17.867902 103.02734 16.576172 103.02734 L 16.435547 103.02734 z M 16.435547 104.2207 L 16.576172 104.2207 C 17.22782 104.2207 17.734375 104.7251 17.734375 105.37695 L 17.734375 106.60742 L 15.277344 106.60742 L 15.277344 105.37695 C 15.277344 104.7251 15.7839 104.2207 16.435547 104.2207 z M 16.435547 105.2207 C 16.301234 105.2207 16.277344 105.24444 16.277344 105.37891 L 16.277344 105.60742 L 16.734375 105.60742 L 16.734375 105.37891 C 16.734375 105.24441 16.712442 105.2207 16.578125 105.2207 L 16.435547 105.2207 z M 4.8808594 117.58984 L 4.8808594 117.59961 C 3.7208594 117.59961 3.5800781 117.90016 3.5800781 119.16016 L 4.7207031 117.7793 C 4.8007031 117.6793 4.9405469 117.63914 5.0605469 117.61914 C 5.2205469 117.61914 5.4 117.6593 5.5 117.7793 L 7.7207031 120.5293 L 14.720703 120.5293 C 15.123595 120.5293 15.408576 120.79174 15.431641 121.20117 C 15.750992 121.09876 16.08404 121.02734 16.435547 121.02734 L 16.578125 121.02734 C 17.24903 121.02734 17.874081 121.23262 18.400391 121.57812 L 18.400391 121.25 C 18.400391 117.05 19.120234 117.61914 14.740234 117.61914 L 7.2792969 117.61914 C 6.1992969 117.61914 5.4208594 117.58984 4.8808594 117.58984 z M 4.9804688 119.33984 C 4.8804688 119.36984 4.81 119.44 4.75 119.5 L 0.80078125 124.43945 C 0.60078125 124.71945 0.8292182 125.2107 1.1992188 125.2207 L 3.5996094 125.2207 L 3.5996094 125.7207 C 3.5996094 129.9807 3.0497656 129.33984 7.2597656 129.33984 L 11.869141 129.33984 L 11.869141 128.11914 L 11.869141 127.52344 L 11.869141 126.44141 C 11.869141 126.43799 11.871073 126.43314 11.871094 126.42969 L 7.2792969 126.42969 C 6.8392969 126.42969 6.5605469 126.13094 6.5605469 125.71094 L 6.5605469 125.21094 L 9.0605469 125.21094 C 9.4305469 125.23094 9.6909375 124.70969 9.4609375 124.42969 L 5.5 119.5 C 5.3820133 119.35252 5.1682348 119.28513 4.9804688 119.33984 z M 12.839844 121.7793 C 12.539844 121.8793 12.410625 122.32055 12.640625 122.56055 L 13.267578 123.34375 C 13.473522 122.72168 13.852237 122.1828 14.353516 121.7793 L 12.839844 121.7793 z M 18.658203 121.7793 C 19.393958 122.37155 19.878978 123.25738 19.916016 124.25781 L 21.279297 122.56055 C 21.499297 122.28055 21.260156 121.7893 20.910156 121.7793 L 18.658203 121.7793 z M 16.435547 122.02734 C 15.143818 122.02734 14.083984 123.08518 14.083984 124.37695 L 14.083984 125.60742 L 13.570312 125.60742 C 13.375448 125.60742 13.210603 125.70409 13.119141 125.79102 C 13.027691 125.87792 12.983569 125.95823 12.951172 126.03125 C 12.886382 126.17727 12.867187 126.30479 12.867188 126.44141 L 12.867188 127.52344 L 12.867188 128.11914 L 12.867188 129.67773 L 12.867188 130.50977 L 13.570312 130.50977 L 19.472656 130.50977 L 20.173828 130.50977 L 20.173828 129.67773 L 20.173828 127.52344 L 20.173828 126.44141 C 20.173828 126.3048 20.156597 126.17728 20.091797 126.03125 C 20.059397 125.95825 20.015299 125.87792 19.923828 125.79102 C 19.832368 125.70412 19.667509 125.60742 19.472656 125.60742 L 18.927734 125.60742 L 18.927734 124.37695 C 18.927734 123.08518 17.867902 122.02734 16.576172 122.02734 L 16.435547 122.02734 z M 16.435547 123.2207 L 16.576172 123.2207 C 17.22782 123.2207 17.734375 123.7251 17.734375 124.37695 L 17.734375 125.60742 L 15.277344 125.60742 L 15.277344 124.37695 C 15.277344 123.7251 15.7839 123.2207 16.435547 123.2207 z M 16.435547 124.2207 C 16.301234 124.2207 16.277344 124.24444 16.277344 124.37891 L 16.277344 124.60742 L 16.734375 124.60742 L 16.734375 124.37891 C 16.734375 124.24441 16.712442 124.2207 16.578125 124.2207 L 16.435547 124.2207 z M 5.9394531 136.58984 L 5.9394531 136.59961 L 8.3105469 139.5293 L 14.730469 139.5293 C 15.131912 139.5293 15.414551 139.79039 15.439453 140.19727 C 15.756409 140.09653 16.087055 140.02734 16.435547 140.02734 L 16.578125 140.02734 C 17.24903 140.02734 17.874081 140.23261 18.400391 140.57812 L 18.400391 140.25 C 18.400391 136.05 19.120234 136.61914 14.740234 136.61914 L 7.2792969 136.61914 C 6.6792969 136.61914 6.3594531 136.59984 5.9394531 136.58984 z M 4.2207031 136.66016 C 3.8207031 136.74016 3.6791406 136.96016 3.6191406 137.41016 L 4.2207031 136.66992 L 4.2207031 136.66016 z M 5.0605469 137.57031 L 5.0605469 137.58984 C 4.9405469 137.58984 4.8197656 137.66953 4.7597656 137.76953 L 0.81054688 142.66992 C 0.57054688 142.96992 0.8109375 143.50023 1.2109375 143.49023 L 3.5996094 143.49023 L 3.5996094 144.71094 C 3.5996094 148.97094 3.0497656 148.33008 7.2597656 148.33008 L 11.869141 148.33008 L 11.869141 147.11914 L 11.869141 146.52344 L 11.869141 145.44141 C 11.869141 145.43799 11.871073 145.43314 11.871094 145.42969 L 7.2792969 145.42969 C 6.8392969 145.42969 6.5605469 145.13094 6.5605469 144.71094 L 6.5605469 143.49023 L 9.0605469 143.49023 C 9.4605469 143.53023 9.7309375 142.95945 9.4609375 142.68945 L 5.5 137.76953 C 5.4 137.63953 5.2305469 137.57031 5.0605469 137.57031 z M 16.435547 141.02734 C 15.143818 141.02734 14.083984 142.08518 14.083984 143.37695 L 14.083984 144.60742 L 13.570312 144.60742 C 13.375448 144.60742 13.210603 144.70409 13.119141 144.79102 C 13.027691 144.87792 12.983569 144.95823 12.951172 145.03125 C 12.886382 145.17727 12.867187 145.30479 12.867188 145.44141 L 12.867188 146.52344 L 12.867188 147.11914 L 12.867188 148.67773 L 12.867188 149.50977 L 13.570312 149.50977 L 19.472656 149.50977 L 20.173828 149.50977 L 20.173828 148.67773 L 20.173828 146.52344 L 20.173828 145.44141 C 20.173828 145.3048 20.156597 145.17728 20.091797 145.03125 C 20.059397 144.95825 20.015299 144.87792 19.923828 144.79102 C 19.832368 144.70412 19.667509 144.60742 19.472656 144.60742 L 18.927734 144.60742 L 18.927734 143.37695 C 18.927734 142.08518 17.867902 141.02734 16.576172 141.02734 L 16.435547 141.02734 z M 12.849609 141.5 C 12.549609 141.6 12.420391 142.0393 12.650391 142.2793 L 13.136719 142.88672 C 13.213026 142.38119 13.390056 141.90696 13.667969 141.5 L 12.849609 141.5 z M 19.34375 141.5 C 19.710704 142.03735 19.927734 142.68522 19.927734 143.37891 L 19.927734 143.79102 C 19.965561 143.80421 20.005506 143.81448 20.044922 143.82617 L 21.289062 142.2793 C 21.509062 141.9993 21.269922 141.51 20.919922 141.5 L 19.34375 141.5 z M 16.435547 142.2207 L 16.576172 142.2207 C 17.22782 142.2207 17.734375 142.7251 17.734375 143.37695 L 17.734375 144.60742 L 15.277344 144.60742 L 15.277344 143.37695 C 15.277344 142.7251 15.7839 142.2207 16.435547 142.2207 z M 16.435547 143.2207 C 16.301234 143.2207 16.277344 143.24444 16.277344 143.37891 L 16.277344 143.60742 L 16.734375 143.60742 L 16.734375 143.37891 C 16.734375 143.24441 16.712442 143.2207 16.578125 143.2207 L 16.435547 143.2207 z M 17.130859 155.59961 C 16.580859 155.57961 15.810469 155.63086 14.730469 155.63086 L 6.5292969 155.63086 L 8.9101562 158.5293 L 14.730469 158.5293 C 15.131912 158.5293 15.414551 158.79039 15.439453 159.19727 C 15.756409 159.09653 16.087055 159.02734 16.435547 159.02734 L 16.578125 159.02734 C 17.24903 159.02734 17.874081 159.23261 18.400391 159.57812 L 18.400391 159.25977 C 18.400391 156.10977 18.800391 155.63961 17.150391 155.59961 L 17.130859 155.59961 z M 5.0292969 155.86914 L 5.0292969 155.88086 C 4.9292969 155.90086 4.83 155.98055 4.75 156.06055 L 0.81054688 160.96094 C 0.61054688 161.26094 0.8409375 161.73977 1.2109375 161.75977 L 3.5996094 161.75977 L 3.5996094 163.7207 C 3.5996094 167.9807 3.0497656 167.33984 7.2597656 167.33984 L 11.869141 167.33984 L 11.869141 166.11914 L 11.869141 165.52344 L 11.869141 164.44141 L 11.869141 164.43945 L 7.2792969 164.43945 C 6.8392969 164.43945 6.5605469 164.1407 6.5605469 163.7207 L 6.5605469 161.75 L 9.0605469 161.75 C 9.4305469 161.77 9.6909375 161.2507 9.4609375 160.9707 L 5.5 156.07031 C 5.4 155.92031 5.1992969 155.84914 5.0292969 155.86914 z M 16.435547 160.02734 C 15.143818 160.02734 14.083984 161.08518 14.083984 162.37695 L 14.083984 163.60742 L 13.570312 163.60742 C 13.375448 163.60742 13.210603 163.70409 13.119141 163.79102 C 13.027691 163.87792 12.983569 163.95823 12.951172 164.03125 C 12.886382 164.17727 12.867187 164.30479 12.867188 164.44141 L 12.867188 165.52344 L 12.867188 166.11914 L 12.867188 167.67773 L 12.867188 168.50977 L 13.570312 168.50977 L 19.472656 168.50977 L 20.173828 168.50977 L 20.173828 167.67773 L 20.173828 165.52344 L 20.173828 164.44141 C 20.173828 164.3048 20.156597 164.17728 20.091797 164.03125 C 20.059397 163.95825 20.015299 163.87792 19.923828 163.79102 C 19.832368 163.70412 19.667509 163.60742 19.472656 163.60742 L 18.927734 163.60742 L 18.927734 162.37695 C 18.927734 161.08518 17.867902 160.02734 16.576172 160.02734 L 16.435547 160.02734 z M 12.900391 161.2207 C 12.580391 161.2807 12.419141 161.74 12.619141 162 L 13.085938 162.58594 L 13.085938 162.37891 C 13.085938 161.97087 13.170592 161.58376 13.306641 161.2207 L 12.900391 161.2207 z M 16.435547 161.2207 L 16.576172 161.2207 C 17.22782 161.2207 17.734375 161.7251 17.734375 162.37695 L 17.734375 163.60742 L 15.277344 163.60742 L 15.277344 162.37695 C 15.277344 161.7251 15.7839 161.2207 16.435547 161.2207 z M 19.708984 161.23047 C 19.842743 161.59081 19.927734 161.97449 19.927734 162.37891 L 19.927734 162.79102 C 20.119162 162.85779 20.322917 162.91147 20.484375 163 L 21.279297 162.00977 C 21.499297 161.72977 21.260156 161.24047 20.910156 161.23047 L 19.708984 161.23047 z M 16.435547 162.2207 C 16.301234 162.2207 16.277344 162.24444 16.277344 162.37891 L 16.277344 162.60742 L 16.734375 162.60742 L 16.734375 162.37891 C 16.734375 162.24441 16.712442 162.2207 16.578125 162.2207 L 16.435547 162.2207 z M 5.0996094 174.49023 L 5.1308594 174.5 C 4.9808594 174.5 4.83 174.56922 4.75 174.69922 L 0.80078125 179.59961 C 0.56078125 179.86961 0.7992182 180.42039 1.1992188 180.40039 L 3.5996094 180.40039 L 3.5996094 182.7207 C 3.5996094 186.9807 3.0497656 186.33984 7.2597656 186.33984 L 11.869141 186.33984 L 11.869141 185.11914 L 11.869141 184.52344 L 11.869141 183.44141 L 11.869141 183.43945 L 7.25 183.43945 C 6.82 183.43945 6.5507814 183.1407 6.5507812 182.7207 L 6.5507812 180.41992 L 9.0507812 180.41992 C 9.4307824 180.44992 9.7092187 179.87984 9.4492188 179.58984 L 5.4804688 174.68945 C 5.3804688 174.55945 5.2496094 174.49023 5.0996094 174.49023 z M 17.150391 174.58008 L 17.130859 174.59961 C 16.580859 174.57961 15.810469 174.63086 14.730469 174.63086 L 6.8300781 174.63086 L 9.1796875 177.5293 L 14.699219 177.5293 C 15.104107 177.5293 15.391475 177.79407 15.412109 178.20703 C 15.737096 178.1006 16.076913 178.02734 16.435547 178.02734 L 16.578125 178.02734 C 17.24903 178.02734 17.874081 178.2326 18.400391 178.57812 L 18.400391 178.24023 C 18.400391 175.09023 18.800391 174.62008 17.150391 174.58008 z M 16.435547 179.02734 C 15.143818 179.02734 14.083984 180.08518 14.083984 181.37695 L 14.083984 182.60742 L 13.570312 182.60742 C 13.375448 182.60742 13.210603 182.70409 13.119141 182.79102 C 13.027691 182.87792 12.983569 182.95823 12.951172 183.03125 C 12.886382 183.17727 12.867187 183.30479 12.867188 183.44141 L 12.867188 184.52344 L 12.867188 185.11914 L 12.867188 186.67773 L 12.867188 187.50977 L 13.570312 187.50977 L 19.472656 187.50977 L 20.173828 187.50977 L 20.173828 186.67773 L 20.173828 184.52344 L 20.173828 183.44141 C 20.173828 183.3048 20.156597 183.17728 20.091797 183.03125 C 20.059397 182.95825 20.015299 182.87792 19.923828 182.79102 C 19.832368 182.70412 19.667509 182.60742 19.472656 182.60742 L 18.927734 182.60742 L 18.927734 181.37695 C 18.927734 180.08518 17.867902 179.02734 16.576172 179.02734 L 16.435547 179.02734 z M 16.435547 180.2207 L 16.576172 180.2207 C 17.22782 180.2207 17.734375 180.7251 17.734375 181.37695 L 17.734375 182.60742 L 15.277344 182.60742 L 15.277344 181.37695 C 15.277344 180.7251 15.7839 180.2207 16.435547 180.2207 z M 19.816406 180.57031 C 19.882311 180.83091 19.927734 181.09907 19.927734 181.37891 L 19.927734 181.79102 C 20.168811 181.87511 20.455966 181.91694 20.613281 182.06641 C 20.630645 182.0829 20.639883 182.10199 20.65625 182.11914 L 21.259766 181.36914 C 21.479766 181.06914 21.240625 180.59031 20.890625 180.57031 L 19.816406 180.57031 z M 12.820312 180.58984 C 12.520316 180.68984 12.389141 181.11914 12.619141 181.36914 L 12.990234 181.83203 C 13.022029 181.82207 13.055579 181.81406 13.085938 181.80273 L 13.085938 181.37891 C 13.085938 181.10616 13.128698 180.84442 13.191406 180.58984 L 12.820312 180.58984 z M 16.435547 181.2207 C 16.301234 181.2207 16.277344 181.24444 16.277344 181.37891 L 16.277344 181.60742 L 16.734375 181.60742 L 16.734375 181.37891 C 16.734375 181.24441 16.712442 181.2207 16.578125 181.2207 L 16.435547 181.2207 z M 4.9609375 193.15039 L 4.9707031 193.16016 C 4.8707031 193.19016 4.8 193.25984 4.75 193.33984 L 0.81054688 198.24023 C 0.61054688 198.54023 0.8409375 199.01906 1.2109375 199.03906 L 3.5996094 199.03906 L 3.5996094 201.7207 C 3.5996094 205.9807 3.0497656 205.33984 7.2597656 205.33984 L 11.869141 205.33984 L 11.869141 204.11914 L 11.869141 203.52344 L 11.869141 202.44141 C 11.869141 202.44141 11.869141 202.43945 11.869141 202.43945 L 7.2695312 202.43945 C 6.8295312 202.43945 6.5507814 202.1407 6.5507812 201.7207 L 6.5507812 199.01953 L 9.0507812 199.01953 C 9.4207814 199.04953 9.6792188 198.54 9.4492188 198.25 L 5.4902344 193.34961 C 5.3702344 193.17961 5.1509375 193.10039 4.9609375 193.15039 z M 17.150391 193.58008 L 17.130859 193.58984 C 16.580859 193.56984 15.810469 193.61914 14.730469 193.61914 L 7.0996094 193.61914 L 9.4199219 196.46094 L 9.4492188 196.51953 L 14.699219 196.51953 C 15.106887 196.51953 15.397075 196.78718 15.414062 197.20508 C 15.738375 197.09913 16.077769 197.02734 16.435547 197.02734 L 16.578125 197.02734 C 17.24903 197.02734 17.874081 197.23259 18.400391 197.57812 L 18.400391 197.24023 C 18.400391 194.09023 18.800391 193.62008 17.150391 193.58008 z M 16.435547 198.02734 C 15.143818 198.02734 14.083984 199.08518 14.083984 200.37695 L 14.083984 201.60742 L 13.570312 201.60742 C 13.375448 201.60742 13.210603 201.70409 13.119141 201.79102 C 13.027691 201.87792 12.983569 201.95823 12.951172 202.03125 C 12.886382 202.17727 12.867187 202.30479 12.867188 202.44141 L 12.867188 203.52344 L 12.867188 204.11914 L 12.867188 205.67773 L 12.867188 206.50977 L 13.570312 206.50977 L 19.472656 206.50977 L 20.173828 206.50977 L 20.173828 205.67773 L 20.173828 203.52344 L 20.173828 202.44141 C 20.173828 202.3048 20.156597 202.17728 20.091797 202.03125 C 20.059397 201.95825 20.015299 201.87792 19.923828 201.79102 C 19.832368 201.70412 19.667509 201.60742 19.472656 201.60742 L 18.927734 201.60742 L 18.927734 200.37695 C 18.927734 199.08518 17.867902 198.02734 16.576172 198.02734 L 16.435547 198.02734 z M 16.435547 199.2207 L 16.576172 199.2207 C 17.22782 199.2207 17.734375 199.7251 17.734375 200.37695 L 17.734375 201.60742 L 15.277344 201.60742 L 15.277344 200.37695 C 15.277344 199.7251 15.7839 199.2207 16.435547 199.2207 z M 12.919922 199.93945 C 12.559922 199.95945 12.359141 200.48023 12.619141 200.74023 L 12.751953 200.9043 C 12.862211 200.87013 12.980058 200.84224 13.085938 200.80273 L 13.085938 200.37891 C 13.085938 200.22863 13.111295 200.08474 13.130859 199.93945 L 12.919922 199.93945 z M 19.882812 199.93945 C 19.902378 200.08474 19.927734 200.22863 19.927734 200.37891 L 19.927734 200.79102 C 20.168811 200.87511 20.455966 200.91694 20.613281 201.06641 C 20.691227 201.14046 20.749315 201.22305 20.806641 201.30273 L 21.259766 200.74023 C 21.519766 200.46023 21.260625 199.90945 20.890625 199.93945 L 19.882812 199.93945 z M 16.435547 200.2207 C 16.301234 200.2207 16.277344 200.24444 16.277344 200.37891 L 16.277344 200.60742 L 16.734375 200.60742 L 16.734375 200.37891 C 16.734375 200.24441 16.712442 200.2207 16.578125 200.2207 L 16.435547 200.2207 z ' fill='#{hex-color($highlight-text-color)}' stroke-width='0' /></svg>"); + } + } + + &.disabled { + i.fa-retweet, + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='209' width='22'><path d='M 18.972656 1.2011719 C 18.829825 1.1881782 18.685932 1.2302188 18.572266 1.3300781 L 15.990234 3.5996094 C 15.58109 3.6070661 15.297269 3.609375 14.730469 3.609375 L 7.0996094 3.609375 L 9.4199219 6.4609375 L 9.4492188 6.5195312 L 12.664062 6.5195312 L 6.5761719 11.867188 C 6.5674697 11.818249 6.5507813 11.773891 6.5507812 11.720703 L 6.5507812 9.0195312 L 9.0507812 9.0195312 C 9.4207813 9.0495313 9.6792188 8.54 9.4492188 8.25 L 5.5 3.3496094 C 5.38 3.1796094 5.1607031 3.1003906 4.9707031 3.1503906 L 4.9707031 3.1601562 C 4.8707031 3.1901563 4.8 3.2598438 4.75 3.3398438 L 0.80078125 8.2402344 C 0.60078125 8.5402344 0.8292187 9.0190625 1.1992188 9.0390625 L 3.5996094 9.0390625 L 3.5996094 11.720703 C 3.5996094 13.045739 3.5690668 13.895038 3.6503906 14.4375 L 2.6152344 15.347656 C 2.3879011 15.547375 2.3754917 15.901081 2.5859375 16.140625 L 3.1464844 16.78125 C 3.3569308 17.020794 3.7101667 17.053234 3.9375 16.853516 L 19.892578 2.8359375 C 20.119911 2.6362188 20.134275 2.282513 19.923828 2.0429688 L 19.361328 1.4023438 C 19.256105 1.282572 19.115488 1.2141655 18.972656 1.2011719 z M 18.410156 6.7753906 L 15.419922 9.4042969 L 15.419922 9.9394531 L 14.810547 9.9394531 L 13.148438 11.400391 L 16.539062 15.640625 C 16.719062 15.890625 17.140313 15.890625 17.320312 15.640625 L 21.259766 10.740234 C 21.519766 10.460234 21.260625 9.9094531 20.890625 9.9394531 L 18.400391 9.9394531 L 18.400391 7.2402344 C 18.400391 7.0470074 18.407711 6.9489682 18.410156 6.7753906 z M 11.966797 12.439453 L 8.6679688 15.339844 L 14.919922 15.339844 L 12.619141 12.5 C 12.589141 12.48 12.590313 12.459453 12.570312 12.439453 L 11.966797 12.439453 z' fill='#{hex-color(darken($action-button-color, 13%))}' stroke-width='0'/></svg>"); + } + } + + .media-modal__overlay .picture-in-picture__footer & { + 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($white)}' 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>"); + } + } +} 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..ad17ed4b0 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -0,0 +1,864 @@ +.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; + + &__panels { + display: flex; + justify-content: center; + width: 100%; + height: 100%; + min-height: 100vh; + + &__pane { + height: 100%; + overflow: hidden; + pointer-events: none; + display: flex; + justify-content: flex-end; + min-width: 285px; + + &--start { + justify-content: flex-start; + } + + &__inner { + position: fixed; + width: 285px; + pointer-events: auto; + height: 100%; + } + } + + &__main { + box-sizing: border-box; + width: 100%; + max-width: 600px; + flex: 0 0 auto; + display: flex; + flex-direction: column; + + @media screen and (min-width: $no-gap-breakpoint) { + padding: 0 10px; + } + } + } +} + +.tabs-bar__wrapper { + background: darken($ui-base-color, 8%); + position: sticky; + top: 0; + z-index: 2; + padding-top: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + padding-top: 10px; + } + + .tabs-bar { + margin-bottom: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + margin-bottom: 10px; + } + } +} + +.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%; +} + +.column { + overflow: hidden; +} + +.column-back-button { + box-sizing: border-box; + width: 100%; + 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, + &:focus, + &:active { + background: lighten($ui-base-color, 11%); + } + + &:focus { + outline: 0; + } + + &--transparent { + background: transparent; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + background: transparent; + color: $primary-text-color; + } + + &.active { + color: $ui-highlight-color; + } + } +} + +.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; + z-index: 1; + + &.active { + box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3); + + &::before { + display: block; + content: ""; + position: absolute; + bottom: -13px; + 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%); + } + } + + .announcements { + z-index: 1; + position: relative; + } +} + +.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: 0; + 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 { + .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 .columns-area:not(.columns-area--mobile) & { + 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 { + margin-bottom: 14px; +} + +.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 5px; + font-size: 14px; + } + + b { + font-weight: bold; + } +} + + +.layout-single-column .column-header__notif-cleaning-buttons { + @media screen and (min-width: $no-gap-breakpoint) { + b, i { + margin-right: 5px; + } + + br { + display: none; + } + + button { + padding: 15px 5px; + } + } +} + +// 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; + z-index: 1; + position: relative; + + &.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__permission-btn { + display: inline; + font-weight: inherit; + 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__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + +.column-header__icon { + display: inline-block; + margin-right: 5px; +} + +.column-settings__pillbar { + display: flex; + overflow: hidden; + background-color: transparent; + border: 0; + border-radius: 4px; + margin-bottom: 10px; + align-items: stretch; +} + +.pillbar-button { + border: 0; + color: #fafafa; + padding: 2px; + margin: 0; + margin-left: 2px; + font-size: inherit; + flex: auto; + background-color: $ui-base-color; + transition-property: background-color, box-shadow; + transition: all 0.2s ease; + + &[disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + &:not([disabled]) { + &:hover { + background-color: darken($ui-base-color, 10%); + } + + &:focus { + background-color: darken($ui-base-color, 15%); + } + + &:active { + background-color: darken($ui-base-color, 20%); + } + + &.active { + background-color: $ui-highlight-color; + box-shadow: inset 0 5px darken($ui-highlight-color, 20%); + + &:hover { + background-color: lighten($ui-highlight-color, 10%); + box-shadow: inset 0 5px darken($ui-highlight-color, 10%); + } + + &:focus { + background-color: lighten($ui-highlight-color, 15%); + box-shadow: inset 0 5px darken($ui-highlight-color, 5%); + } + + &:active { + background-color: lighten($ui-highlight-color, 20%); + box-shadow: inset 0 5px $ui-highlight-color; + } + } + } + + /* TODO: check RTL? */ + &:first-child { + margin-left: 0; + } +} + +.empty-column-indicator, +.error-column, +.follow_requests-unlocked_explanation { + 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; + } + + & > span { + max-width: 400px; + } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.follow_requests-unlocked_explanation { + background: darken($ui-base-color, 4%); + contain: initial; +} + +.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: $no-gap-breakpoint) { + @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: $no-gap-breakpoint) { + 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; + } +} + +.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__row--with-margin { + margin-bottom: 15px; +} + +.column-settings__hashtags { + .column-settings__row { + margin-bottom: 15px; + } + + .column-select { + &__control { + @include search-input(); + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-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; + } + } + + &__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:not(.column-header__permission-btn) { + margin-bottom: 15px; + } +} + +.notifications-permission-banner { + padding: 30px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + + &__close { + position: absolute; + top: 10px; + right: 10px; + } + + h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + text-align: center; + } + + p { + color: $darker-text-color; + margin-bottom: 15px; + text-align: center; + } +} + +.column-title { + text-align: center; + padding: 40px; + + .logo { + fill: $primary-text-color; + width: 50px; + margin: 0 auto; + margin-bottom: 40px; + } + + h3 { + font-size: 24px; + line-height: 1.5; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + } +} + +.follow-recommendations-container { + display: flex; + flex-direction: column; +} + +.column-actions { + display: flex; + align-items: start; + justify-content: center; + padding: 40px; + padding-top: 40px; + padding-bottom: 200px; + flex-grow: 1; + position: relative; + + &__background { + position: absolute; + left: 0; + bottom: 0; + height: 220px; + width: auto; + } +} + +.column-list { + margin: 0 20px; + border: 1px solid lighten($ui-base-color, 8%); + background: darken($ui-base-color, 2%); + border-radius: 4px; + + &__empty-message { + padding: 40px; + text-align: center; + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + } +} 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..fd62bb651 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -0,0 +1,670 @@ +.composer { + padding: 10px; + + .emoji-picker-dropdown { + position: absolute; + top: 0; + right: 0; + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } + } +} + +.character-counter { + cursor: default; + font-family: $font-sans-serif, sans-serif; + font-size: 14px; + font-weight: 600; + color: $lighter-text-color; + + &.character-counter--over { + color: $warning-red; + } +} + +.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: 36px; + margin-bottom: 11px; + opacity: 1.0; + } + + input { + display: block; + box-sizing: border-box; + margin: 0; + border: 0; + 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; + + &::placeholder { + color: $dark-text-color; + } + + &: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; + + font-size: 14px; + font-weight: 500; + + &.active { + color: $highlight-text-color; + } + + input[type=checkbox] { + display: none; + } + + .checkbox { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-left: 5px; + margin-right: 10px; + top: -1px; + border-radius: 4px; + vertical-align: middle; + + &.active { + border-color: $highlight-text-color; + background: $highlight-text-color; + } + } +} + +.composer--reply { + margin: 0 0 10px; + border-radius: 4px; + padding: 10px; + background: $ui-primary-color; + min-height: 23px; + overflow-y: auto; + flex: 0 2 auto; + + & > 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; + overflow: hidden; + + p, pre, blockquote { + margin-bottom: 20px; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0; + } + } + + 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 $inverted-text-color; + color: $inverted-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: $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; + } +} + +.compose-form__autosuggest-wrapper, +.autosuggest-input { + position: relative; + width: 100%; + + label { + .autosuggest-textarea__textarea { + display: block; + box-sizing: border-box; + margin: 0; + border: 0; + 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; + + &::placeholder { + color: $dark-text-color; + } + + &::-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-wrapper { + position: relative; + height: 0; +} + +.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 { + padding: 10px; + cursor: pointer; + border-radius: 4px; + + &:hover, + &:focus, + &:active, + &.selected { background: darken($ui-secondary-color, 10%) } + + > .account, + > .emoji, + > .autosuggest-hashtag { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + line-height: 18px; + font-size: 14px; + } + + .autosuggest-hashtag { + justify-content: space-between; + + &__name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + font-weight: 500; + } + + &__uses { + flex: 0 0 auto; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + & > .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-color: $base-shadow-color; + 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-wrapper { + padding: 10px; + background: darken($simple-background-color, 8%); + border-radius: 0 0 4px 4px; + height: 27px; + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.composer--options { + display: flex; + flex: 0 0 auto; + + & > * { + 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; + } +} + +.compose--counter-wrapper { + align-self: center; + margin-right: 4px; +} + +.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; + justify-content: flex-end; + flex: 0 0 auto; + + & > .primary { + display: inline-block; + margin: 0; + padding: 0 10px; + text-align: center; + } + + & > .side_arm { + display: inline-block; + margin: 0 2px; + padding: 0; + width: 36px; + text-align: center; + } + + &.over { + & > .count { color: $warning-red } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss new file mode 100644 index 000000000..b0ad5a88a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/directory.scss @@ -0,0 +1,180 @@ +.directory { + &__list { + width: 100%; + margin: 10px 0; + transition: opacity 100ms ease-in; + + &.loading { + opacity: 0.7; + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin: 0; + } + } + + &__card { + box-sizing: border-box; + margin-bottom: 10px; + + &__img { + height: 125px; + position: relative; + background: darken($ui-base-color, 12%); + overflow: hidden; + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + } + + &__bar { + display: flex; + align-items: center; + background: lighten($ui-base-color, 4%); + padding: 10px; + + &__name { + flex: 1 1 auto; + display: flex; + align-items: center; + text-decoration: none; + overflow: hidden; + } + + &__relationship { + width: 23px; + min-height: 1px; + flex: 0 0 auto; + } + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + background: darken($ui-base-color, 8%); + object-fit: cover; + } + } + + .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; + } + } + } + + &__extra { + background: $ui-base-color; + display: flex; + align-items: center; + justify-content: center; + + .accounts-table__count { + width: 33.33%; + flex: 0 0 auto; + padding: 15px 0; + } + + .account__header__content { + box-sizing: border-box; + padding: 15px 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + width: 100%; + min-height: 18px + 30px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + p { + display: none; + + &:first-child { + display: inline; + } + } + + br { + display: none; + } + } + } + } +} + +.filter-form { + background: $ui-base-color; + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } +} + +.radio-button { + font-size: 14px; + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + &__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; + + &.checked { + border-color: lighten($ui-highlight-color, 8%); + background: lighten($ui-highlight-color, 8%); + } + } +} 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..b6d06f53a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -0,0 +1,280 @@ +.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; + } + } +} + +.search { + position: relative; + margin-bottom: 10px; + flex: none; + + @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 } + @include single-column('screen and (max-width: 630px)') { font-size: 16px } +} + +.search-popout { + @include search-popout(); +} + +.drawer--account { + padding: 10px; + color: $darker-text-color; + display: flex; + align-items: center; + + a { + color: inherit; + text-decoration: none; + } + + .acct { + display: block; + color: $secondary-text-color; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.navigation-bar__profile { + flex: 1 1 auto; + margin-left: 8px; + overflow: hidden; +} + +.drawer--results { + background: $ui-base-color; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1 1 auto; + + & > header { + color: $dark-text-color; + background: lighten($ui-base-color, 2%); + padding: 15px; + font-weight: 500; + font-size: 16px; + cursor: default; + flex: 0 0 auto; + + .fa { + display: inline-block; + margin-right: 5px; + } + } + + & > .search-results__contents { + overflow-x: hidden; + overflow-y: scroll; + flex: 1 1 auto; + + & > 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; + display: none; + + > img { + display: block; + object-fit: contain; + object-position: bottom left; + width: 85%; + height: 100%; + pointer-events: none; + user-drag: none; + user-select: none; + } + + > .mastodon { + display: block; + width: 100%; + height: 100%; + border: 0; + cursor: inherit; + } + + @media screen and (min-height: 640px) { + display: block; + } +} + +.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..9dfee346a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/emoji.scss @@ -0,0 +1,101 @@ +.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; + z-index: 2; + + .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; + padding: 5px 5px 2px 2px; + outline: 0; + cursor: pointer; + + &:active, + &:focus { + outline: 0 !important; + } + + img { + filter: grayscale(100%); + opacity: 0.8; + display: block; + margin: 0; + width: 22px; + height: 22px; + } + + &: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..3176690e2 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/error_boundary.scss @@ -0,0 +1,30 @@ +.error-boundary { + color: $primary-text-color; + font-size: 15px; + line-height: 20px; + + h1 { + font-size: 26px; + line-height: 36px; + font-weight: 400; + margin-bottom: 8px; + } + + 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..24f750e1d --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -0,0 +1,1694 @@ +.app-body { + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.animated-number { + display: inline-flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; + position: relative; +} + +.link-button { + display: block; + font-size: 15px; + line-height: 20px; + color: $ui-highlight-color; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + + &:hover, + &:active { + text-decoration: underline; + } + + &:disabled { + color: $ui-primary-color; + cursor: default; + } +} + +.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%; + } + + .layout-multiple-columns &.button--with-bell { + font-size: 12px; + padding: 0 8px; + } +} + +.icon-button { + display: inline-block; + padding: 0; + color: $action-button-color; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + transition: all 100ms ease-in; + transition-property: background-color, color; + text-decoration: none; + + &:hover, + &:active, + &:focus { + color: lighten($action-button-color, 7%); + background-color: rgba($action-button-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($action-button-color, 0.3); + } + + &.disabled { + color: darken($action-button-color, 13%); + background-color: transparent; + 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%); + background-color: rgba($lighter-text-color, 0.15); + } + + &:focus { + background-color: rgba($lighter-text-color, 0.3); + } + + &.disabled { + color: lighten($lighter-text-color, 7%); + background-color: transparent; + } + + &.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); + } + } + + &--with-counter { + display: inline-flex; + align-items: center; + width: auto !important; + } + + &__counter { + display: inline-block; + width: 14px; + margin-left: 4px; + font-size: 12px; + font-weight: 500; + } +} + +.text-icon-button { + color: $lighter-text-color; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-weight: 600; + font-size: 11px; + padding: 0 3px; + line-height: 27px; + outline: 0; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + color: darken($lighter-text-color, 7%); + background-color: rgba($lighter-text-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($lighter-text-color, 0.3); + } + + &.disabled { + color: lighten($lighter-text-color, 20%); + background-color: transparent; + 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; + } +} + +.icon-button.star-icon.active { + color: $gold-star; +} + +.icon-button.bookmark-icon.active { + color: $red-bookmark; +} + +.no-reduce-motion .icon-button.star-icon { + &.activate { + & > .fa-star { + animation: spring-rotate-in 1s linear; + } + } + + &.deactivate { + & > .fa-star { + animation: spring-rotate-out 1s linear; + } + } +} + +.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; + + a { + color: inherit; + text-decoration: inherit; + } + + strong { + display: block; + } + + > a: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; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE 10+ */ + + * { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE 10+ */ + } + + &::-webkit-scrollbar, + *::-webkit-scrollbar { + width: 0; + height: 0; + background: transparent; /* Chrome/Safari/Webkit */ + } + + .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); + z-index: 9999; + + 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; + } +} + +.column, +.drawer { + flex: 1 1 100%; + overflow: hidden; +} + +@media screen and (min-width: 631px) { + .columns-area { + padding: 0; + } + + .column, + .drawer { + 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, + .drawer { + padding-left: 5px; + padding-right: 5px; + } + } +} + +.tabs-bar { + box-sizing: border-box; + 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; + padding-bottom: 13px; + 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; + } + + &:hover, + &:focus, + &:active { + @include multi-columns('screen and (min-width: 631px)') { + background: lighten($ui-base-color, 14%); + border-bottom-color: lighten($ui-base-color, 14%); + } + } + + &.active { + border-bottom: 2px solid $ui-highlight-color; + color: $highlight-text-color; + } + + span { + margin-left: 5px; + display: none; + } + + span.icon { + margin-left: 0; + display: inline; + } +} + +.icon-with-badge { + position: relative; + + &__badge { + position: absolute; + left: 9px; + top: -13px; + background: $ui-highlight-color; + border: 2px solid lighten($ui-base-color, 8%); + padding: 1px 6px; + border-radius: 6px; + font-size: 10px; + font-weight: 500; + line-height: 14px; + color: $primary-text-color; + } + + &__issue-badge { + position: absolute; + left: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } +} + +.column-link--transparent .icon-with-badge__badge { + border-color: darken($ui-base-color, 8%); +} + +.scrollable { + overflow-y: scroll; + overflow-x: hidden; + flex: 1 1 auto; + -webkit-overflow-scrolling: touch; + + &.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, +.flex-spacer { + background: $ui-base-color; +} + +.getting-started__wrapper { + position: relative; + overflow-y: auto; +} + +.flex-spacer { + flex: 1 1 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; + z-index: 1; + + 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; + } + } + } + + &__trends { + flex: 0 1 auto; + opacity: 1; + animation: fade 150ms linear; + margin-top: 10px; + + h4 { + font-size: 12px; + text-transform: uppercase; + color: $darker-text-color; + padding: 10px; + font-weight: 500; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + @media screen and (max-height: 810px) { + .trends__item:nth-child(3) { + display: none; + } + } + + @media screen and (max-height: 720px) { + .trends__item:nth-child(2) { + display: none; + } + } + + @media screen and (max-height: 670px) { + display: none; + } + + .trends__item { + border-bottom: 0; + padding: 10px; + + &__current { + color: $darker-text-color; + } + } + } +} + +.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: 0; + 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; + } + } +} + +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; + } +} + +button.icon-button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.reduce-motion button.icon-button i.fa-retweet, +.reduce-motion button.icon-button.active i.fa-retweet { + transition: none; +} + +.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%); +} + +.timeline-hint { + text-align: center; + color: $darker-text-color; + padding: 15px; + box-sizing: border-box; + width: 100%; + cursor: default; + + strong { + font-weight: 500; + } + + a { + color: lighten($ui-highlight-color, 8%); + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + color: lighten($ui-highlight-color, 12%); + } + } +} + +.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; + 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 spring-rotate-in { + 0% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-484.8deg); + } + + 60% { + transform: rotate(-316.7deg); + } + + 90% { + transform: rotate(-375deg); + } + + 100% { + transform: rotate(-360deg); + } +} + +@keyframes spring-rotate-out { + 0% { + transform: rotate(-360deg); + } + + 30% { + transform: rotate(124.8deg); + } + + 60% { + transform: rotate(-43.27deg); + } + + 90% { + transform: rotate(15deg); + } + + 100% { + transform: rotate(0deg); + } +} + +@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; + } + + &--click-thru { + pointer-events: none; + } + + &--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); + } + } + + &:disabled { + .spoiler-button__overlay__label { + background: rgba($base-overlay-background, 0.5); + } + } + } +} + +.setting-toggle { + display: block; + line-height: 24px; +} + +.setting-toggle__label, +.setting-meta__label { + color: $darker-text-color; + display: inline-block; + margin-bottom: 14px; + margin-left: 8px; + vertical-align: middle; +} + +.column-settings__row .radio-button { + display: block; +} + +.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; + z-index: 9999; +} + +.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; +} + +.conversation { + display: flex; + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 5px; + padding-bottom: 0; + + &:focus { + background: lighten($ui-base-color, 2%); + outline: 0; + } + + &__avatar { + flex: 0 0 auto; + padding: 10px; + padding-top: 12px; + position: relative; + cursor: pointer; + } + + &__unread { + display: inline-block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + margin: -.1ex .15em .1ex; + } + + &__content { + flex: 1 1 auto; + padding: 10px 5px; + padding-right: 15px; + overflow: hidden; + + &__info { + overflow: hidden; + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + } + + &__relative-time { + font-size: 15px; + color: $darker-text-color; + padding-left: 15px; + } + + &__names { + color: $darker-text-color; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + flex-basis: 90px; + flex-grow: 1; + + a { + color: $primary-text-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + } + + .status__content { + margin: 0; + } + } + + &--unread { + background: lighten($ui-base-color, 2%); + + &:focus { + background: lighten($ui-base-color, 4%); + } + + .conversation__content__info { + font-weight: 700; + } + + .conversation__content__relative-time { + color: $primary-text-color; + } + } +} + +.ui .flash-message { + margin-top: 10px; + margin-left: auto; + margin-right: auto; + margin-bottom: 0; + min-width: 75%; +} + +::-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; + } + } + + a { + word-break: break-word; + } + } +} + +@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 'directory'; +@import 'search'; +@import 'emoji'; +@import 'doodle'; +@import 'drawer'; +@import 'media'; +@import 'sensitive'; +@import 'lists'; +@import 'emoji_picker'; +@import 'local_settings'; +@import 'error_boundary'; +@import 'single_column'; +@import 'announcements'; 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..88212457f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -0,0 +1,781 @@ +.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 { + &: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%; + min-height: 64px; + + @include fullwidth-gallery; +} + +.media-gallery__item { + border: 0; + 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; + 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__container { + max-width: 100vw; + max-height: 100vh; +} + +.audio-modal__container { + width: 50vw; +} + +.media-modal { + width: 100%; + height: 100%; + position: relative; + + &__close, + &__zoom-button { + color: rgba($white, 0.7); + + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } + + &:focus { + background-color: rgba($white, 0.3); + } + } +} + +.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: transparent; + box-sizing: border-box; + border: 0; + color: rgba($primary-text-color, 0.7); + cursor: pointer; + display: flex; + align-items: center; + font-size: 24px; + height: 20vmax; + margin: auto 0; + padding: 30px 15px; + position: absolute; + top: 0; + bottom: 0; + + &:hover, + &:focus, + &:active { + color: $primary-text-color; + } +} + +.media-modal__nav--left { + left: 0; +} + +.media-modal__nav--right { + right: 0; +} + +.media-modal__overlay { + max-width: 600px; + position: absolute; + left: 0; + right: 0; + bottom: 0; + margin: 0 auto; + + .picture-in-picture__footer { + border-radius: 0; + background: transparent; + padding: 20px 0; + + .icon-button { + color: $white; + + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } + + &:focus { + background-color: rgba($white, 0.3); + } + + &.active { + color: $highlight-text-color; + + &:hover, + &:focus, + &:active { + background: rgba($highlight-text-color, 0.15); + } + + &:focus { + background: rgba($highlight-text-color, 0.3); + } + } + + &.star-icon.active { + color: $gold-star; + + &:hover, + &:focus, + &:active { + background: rgba($gold-star, 0.15); + } + + &:focus { + background: rgba($gold-star, 0.3); + } + } + } + } +} + +.media-modal__pagination { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.media-modal__page-dot { + flex: 0 0 auto; + background-color: $white; + opacity: 0.4; + height: 6px; + width: 6px; + border-radius: 50%; + margin: 0 4px; + padding: 0; + border: 0; + font-size: 0; + transition: opacity .2s ease-in-out; + + &.active { + opacity: 1; + } +} + +.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; + } + +} + +.audio-player { + overflow: hidden; + box-sizing: border-box; + position: relative; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding-bottom: 44px; + direction: ltr; + + &.editable { + border-radius: 0; + height: 100%; + } + + .video-player__volume::before, + .video-player__seek::before { + background: currentColor; + opacity: 0.15; + } + + .video-player__seek__buffer { + background: currentColor; + opacity: 0.2; + } + + .video-player__buttons button { + color: currentColor; + opacity: 0.75; + + &:active, + &:hover, + &:focus { + color: currentColor; + opacity: 1; + } + } + + .video-player__time-sep, + .video-player__time-total, + .video-player__time-current { + color: currentColor; + } + + .video-player__seek::before, + .video-player__seek__buffer, + .video-player__seek__progress { + top: 0; + } + + .video-player__seek__handle { + top: -4px; + } + + .video-player__controls { + padding-top: 10px; + background: transparent; + } +} + +.video-player { + overflow: hidden; + position: relative; + background: $base-shadow-color; + max-width: 100%; + border-radius: 4px; + box-sizing: border-box; + direction: ltr; + color: $white; + + &.editable { + border-radius: 0; + height: 100% !important; + } + + &:focus { + outline: 0; + } + + .detailed-status & { + width: 100%; + height: 100%; + } + + @include fullwidth-gallery; + + video { + display: block; + 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; + outline: 0; + } + } + + &.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: 8px; + margin: 0 -5px; + + .video-player__download__icon { + color: inherit; + + .fa, + &:active .fa, + &:hover .fa, + &:focus .fa { + color: inherit; + } + } + } + + &__buttons { + display: flex; + flex: 0 1 auto; + min-width: 30px; + align-items: center; + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .player-button { + display: inline-block; + outline: 0; + + flex: 0 0 auto; + background: transparent; + padding: 5px; + font-size: 16px; + border: 0; + color: rgba($white, 0.75); + + &:active, + &:hover, + &:focus { + color: $white; + } + } + } + + &__time { + display: inline; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + margin: 0 5px; + } + + &__time-sep, + &__time-total, + &__time-current { + font-size: 14px; + font-weight: 500; + } + + &__time-current { + color: $white; + } + + &__time-sep { + display: inline-block; + margin: 0 6px; + } + + &__time-sep, + &__time-total { + color: $white; + } + + &__volume { + flex: 0 0 auto; + display: inline-flex; + cursor: pointer; + height: 24px; + position: relative; + overflow: hidden; + + .no-reduce-motion & { + transition: all 100ms linear; + } + + &.active { + overflow: visible; + width: 50px; + margin-right: 16px; + } + + &::before { + content: ""; + width: 50px; + background: rgba($white, 0.35); + border-radius: 4px; + display: block; + position: absolute; + height: 4px; + left: 0; + top: 50%; + transform: translate(0, -50%); + } + + &__current { + display: block; + position: absolute; + height: 4px; + border-radius: 4px; + left: 0; + top: 50%; + transform: translate(0, -50%); + background: lighten($ui-highlight-color, 8%); + } + + &__handle { + position: absolute; + z-index: 3; + border-radius: 50%; + width: 12px; + height: 12px; + top: 50%; + left: 0; + margin-left: -6px; + transform: translate(0, -50%); + background: lighten($ui-highlight-color, 8%); + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + opacity: 0; + + .no-reduce-motion & { + transition: opacity 100ms linear; + } + } + + &.active &__handle { + opacity: 1; + } + } + + &__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: 14px; + } + + &__progress, + &__buffer { + display: block; + position: absolute; + height: 4px; + border-radius: 4px; + top: 14px; + 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: 10px; + margin-left: -6px; + background: lighten($ui-highlight-color, 8%); + box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2); + + .no-reduce-motion & { + transition: opacity .1s ease; + } + + &.active { + opacity: 1; + } + } + + &:hover { + .video-player__seek__handle { + opacity: 1; + } + } + } + + &.detailed, + &.fullscreen { + .video-player__buttons { + .player-button { + padding-top: 10px; + padding-bottom: 10px; + } + } + } +} + +.gifv { + video { + max-width: 100vw; + max-height: 80vh; + } +} 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..cb776e88f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -0,0 +1,1044 @@ +.modal-container--preloader { + background: lighten($ui-base-color, 8%); +} + +.modal-root { + position: relative; + z-index: 9999; +} + +.modal-root__overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba($base-overlay-background, 0.7); + transition: background 0.5s; +} + +.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; +} + +.media-modal__zoom-button { + position: absolute; + right: 64px; + top: 8px; + z-index: 100; + pointer-events: auto; + transition: opacity 0.3s linear; + will-change: opacity; +} + +.media-modal__zoom-button--hidden { + pointer-events: none; + opacity: 0; +} + +.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, +.block-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__visibility-icon { + color: $dark-text-color; + font-size: 14px; + padding: 0 4px; + } + + .status__display-name { + display: flex; + } + + .status__avatar { + height: 48px; + width: 48px; + } + + .status__content__spoiler-link { + color: lighten($secondary-text-color, 8%); + } +} + +.favourite-modal .status-direct { + background-color: inherit; +} + +.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, +.block-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, +.block-modal { + line-height: 24px; +} + +.mute-modal .react-toggle, +.block-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, +.focal-point-modal__content { + 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; + } +} + +.focal-point-modal__content { + @media screen and (max-width: 480px) { + max-height: 40vh; + } +} + +.setting-divider { + background: transparent; + border: 0; + margin: 0; + width: 100%; + height: 1px; + margin-bottom: 29px; +} + +.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: none; + border: 0; + outline: 0; + border-radius: 4px; + border: 1px solid $ui-secondary-color; + min-height: 100px; + max-height: 50vh; + margin-bottom: 10px; + + &:focus { + border: 1px solid darken($ui-secondary-color, 8%); + } + + &__wrapper { + background: $white; + border: 1px solid $ui-secondary-color; + margin-bottom: 10px; + border-radius: 4px; + + .setting-text { + border: 0; + margin-bottom: 0; + border-radius: 0; + + &:focus { + border: 0; + } + } + + &__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $white; + } + } + + &__toolbar { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + } + } + + .setting-text-label { + display: block; + color: $inverted-text-color; + font-size: 14px; + font-weight: 500; + margin-bottom: 10px; + } + + .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; + } + } +} + +.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, +.block-modal__action-bar { + .confirmation-modal__secondary-button { + flex-shrink: 1; + } +} + +.confirmation-modal__secondary-button, +.confirmation-modal__cancel-button, +.mute-modal__cancel-button, +.block-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%); + background-color: transparent; + } +} + +.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, +.block-modal__container, +.report-modal__target { + padding: 30px; + font-size: 16px; + + strong { + font-weight: 500; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + + 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; + } +} + +.confirmation-modal__container, +.report-modal__target { + text-align: center; +} + +.block-modal, +.mute-modal { + &__explanation { + margin-top: 20px; + } + + .setting-toggle { + margin-top: 20px; + margin-bottom: 24px; + display: flex; + align-items: center; + + &__label { + color: $inverted-text-color; + margin: 0; + margin-left: 8px; + } + } +} + +.report-modal__target { + padding: 15px; + + .report-modal__close { + position: absolute; + top: 10px; + right: 10px; + } +} + +.embed-modal { + width: auto; + 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: 0; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $primary-text-color; + font-size: 14px; + margin: 0; + margin-bottom: 15px; + border-radius: 4px; + + &::-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; + border-radius: 4px; + } + } +} + +.focal-point { + position: relative; + cursor: move; + overflow: hidden; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: $base-shadow-color; + + img, + video, + canvas { + display: block; + max-height: 80vh; + width: 100%; + height: auto; + margin: 0; + object-fit: contain; + background: $base-shadow-color; + } + + &__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; + } + + &__preview { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 2; + cursor: move; + transition: opacity 0.1s ease; + + &:hover { + opacity: 0.5; + } + + strong { + color: $primary-text-color; + font-size: 14px; + font-weight: 500; + display: block; + margin-bottom: 5px; + } + + div { + border-radius: 4px; + box-shadow: 0 0 14px rgba($base-shadow-color, 0.2); + } + } + + @media screen and (max-width: 480px) { + img, + video { + max-height: 100%; + } + + &__preview { + display: none; + } + } +} + +.filtered-status-info { + text-align: start; + + .spoiler__text { + margin-top: 20px; + } + + .account { + border-bottom: 0; + } + + .account__display-name strong { + color: $inverted-text-color; + } + + .status__content__spoiler { + display: none; + + &--visible { + display: flex; + } + } + + ul { + padding: 10px; + margin-left: 12px; + list-style: disc inside; + } + + .filtered-status-edit-link { + color: $action-button-color; + text-decoration: none; + + &:hover { + text-decoration: underline + } + } +} + +.modal-root__container .composer--options--dropdown { + flex-grow: 0; +} + +.modal-root__container .composer--options--dropdown--content { + pointer-events: auto; + z-index: 9999; +} 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..c65e6a9af --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss @@ -0,0 +1,43 @@ +.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; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + + &__figure { + &, + img { + display: block; + width: auto; + height: 160px; + margin: 0; + } + } + + &--without-header { + padding-top: 20px + 48px; + } + + &__label { + margin-top: 30px; + + 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..eec2e64d6 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/search.scss @@ -0,0 +1,192 @@ +.search { + position: relative; +} + +.search__input { + @include search-input(); + + display: block; + padding: 15px; + padding-right: 30px; + line-height: 18px; + font-size: 16px; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-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; + } +} + +.search__icon { + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus { + outline: 0 !important; + } + + .fa { + position: absolute; + top: 16px; + right: 10px; + z-index: 2; + display: inline-block; + opacity: 0; + transition: all 100ms linear; + transition-property: color, 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(0deg); + + &.active { + pointer-events: auto; + opacity: 0.3; + } + } + + .fa-times-circle { + top: 17px; + transform: rotate(0deg); + color: $action-button-color; + cursor: pointer; + + &.active { + transform: rotate(90deg); + } + + &:hover { + color: lighten($action-button-color, 7%); + } + } +} + +.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; +} + +.search-results__info { + padding: 20px; + color: $darker-text-color; + text-align: center; +} + +.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; + font-size: 24px; + line-height: 36px; + font-weight: 500; + text-align: right; + padding-right: 15px; + margin-left: 5px; + color: $secondary-text-color; + } + + &__sparkline { + flex: 0 0 auto; + width: 50px; + + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { + stroke: lighten($highlight-text-color, 6%) !important; + fill: none !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/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss new file mode 100644 index 000000000..edf705b5f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/single_column.scss @@ -0,0 +1,269 @@ +.compose-panel { + width: 285px; + margin-top: 10px; + display: flex; + flex-direction: column; + height: calc(100% - 10px); + overflow-y: hidden; + + .search__input { + line-height: 18px; + font-size: 16px; + padding: 15px; + padding-right: 30px; + } + + .search__icon .fa { + top: 15px; + } + + .drawer--account { + flex: 0 1 48px; + } + + .flex-spacer { + background: transparent; + } + + .composer { + flex: 1; + overflow-y: hidden; + display: flex; + flex-direction: column; + min-height: 310px; + } + + .compose-form__autosuggest-wrapper { + overflow-y: auto; + background-color: $white; + border-radius: 4px 4px 0 0; + flex: 0 1 auto; + } + + .autosuggest-textarea__textarea { + overflow-y: hidden; + } + + .compose-form__upload-thumbnail { + height: 80px; + } +} + +.navigation-panel { + margin-top: 10px; + margin-bottom: 10px; + height: calc(100% - 20px); + overflow-y: auto; + display: flex; + flex-direction: column; + + & > a { + flex: 0 0 auto; + } + + hr { + flex: 0 0 auto; + border: 0; + background: transparent; + border-top: 1px solid lighten($ui-base-color, 4%); + margin: 10px 0; + } + + .flex-spacer { + background: transparent; + } +} + +@media screen and (min-width: 600px) { + .tabs-bar__link { + span { + display: inline; + } + } +} + +.columns-area--mobile { + flex-direction: column; + width: 100%; + margin: 0 auto; + + .column, + .drawer { + width: 100%; + height: 100%; + padding: 0; + } + + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + } + + .directory__card { + margin-bottom: 0; + } + + .filter-form { + display: flex; + } + + .autosuggest-textarea__textarea { + font-size: 16px; + } + + .search__input { + line-height: 18px; + font-size: 16px; + padding: 15px; + padding-right: 30px; + } + + .search__icon .fa { + top: 15px; + } + + .scrollable { + overflow: visible; + + @supports(display: grid) { + contain: content; + } + } + + @media screen and (min-width: $no-gap-breakpoint) { + padding: 10px 0; + padding-top: 0; + } + + @media screen and (min-width: 630px) { + .detailed-status { + padding: 15px; + + .media-gallery, + .video-player, + .audio-player { + margin-top: 15px; + } + } + + .account__header__bar { + padding: 5px 10px; + } + + .navigation-bar, + .compose-form { + padding: 15px; + } + + .compose-form .compose-form__publish .compose-form__publish-button-wrapper { + padding-top: 15px; + } + + .status { + padding: 15px; + min-height: 48px + 2px; + + .media-gallery, + &__action-bar, + .video-player, + .audio-player { + margin-top: 10px; + } + } + + .account { + padding: 15px 10px; + + &__header__bio { + margin: 0 -10px; + } + } + + .notification { + &__message { + padding-top: 15px; + } + + .status { + padding-top: 8px; + } + + .account { + padding-top: 8px; + } + } + } +} + +.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%); + } +} + +@media screen and (min-width: $no-gap-breakpoint) { + .tabs-bar { + width: 100%; + } + + .react-swipeable-view-container .columns-area--mobile { + height: calc(100% - 10px) !important; + } + + .getting-started__wrapper, + .search { + margin-bottom: 10px; + } +} + +@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) { + .columns-area__panels__pane--compositional { + display: none; + } +} + +@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) { + .floating-action-button, + .tabs-bar__link.optional { + display: none; + } + + .search-page .search { + display: none; + } +} + +@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) { + .columns-area__panels__pane--navigational { + display: none; + } +} + +@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) { + .tabs-bar { + display: none; + } +} 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..e906a7261 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -0,0 +1,1153 @@ +@keyframes spring-flip-in { + 0% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-242.4deg); + } + + 60% { + transform: rotate(-158.35deg); + } + + 90% { + transform: rotate(-187.5deg); + } + + 100% { + transform: rotate(-180deg); + } +} + +@keyframes spring-flip-out { + 0% { + transform: rotate(-180deg); + } + + 30% { + transform: rotate(62.4deg); + } + + 60% { + transform: rotate(-21.635deg); + } + + 90% { + transform: rotate(7.5deg); + } + + 100% { + transform: rotate(0deg); + } +} + +.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; + clear: both; + + &:focus { + outline: 0; + } + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p, pre, blockquote { + margin-bottom: 20px; + white-space: pre-wrap; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + } + + .status__content__text, + .e-content { + overflow: hidden; + + & > ul, + & > ol { + margin-bottom: 20px; + } + + h1, h2, h3, h4, h5 { + margin-top: 20px; + margin-bottom: 20px; + } + + h1, h2 { + font-weight: 700; + font-size: 1.2em; + } + + h2 { + font-size: 1.1em; + } + + 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; + } + + sup { + font-size: smaller; + vertical-align: super; + } + + 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; + unicode-bidi: isolate; + + &: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; + } + } + + a.unhandled-link { + color: lighten($ui-highlight-color, 8%); + + .link-origin-tag { + color: $gold-star; + font-size: 0.8em; + } + } + + .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: 0; + 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, + .notification-follow-request { + 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 { + left: -26px; + position: absolute; +} + +.notification-follow, +.notification-follow-request { + 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: auto; + + @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, + .audio-player { + margin-top: 8px; + } + + &.status-direct { + background: lighten($ui-base-color, 8%); + border-bottom-color: lighten($ui-base-color, 12%); + } + + &.light { + .status__relative-time { + color: $lighter-text-color; + } + + .status__display-name { + color: $inverted-text-color; + } + + .display-name { + color: $light-text-color; + + strong { + color: $inverted-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; + flex-grow: 1; + color: $dark-text-color; + font-size: 14px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status__display-name { + color: $dark-text-color; + overflow: hidden; +} + +.status__info__account .status__display-name { + display: block; + max-width: 100%; +} + +.status__info { + display: flex; + justify-content: space-between; + font-size: 15px; + + > span { + text-overflow: ellipsis; + overflow: hidden; + } + + .notification__message > span { + word-wrap: break-word; + } +} + +.status__info__icons { + display: flex; + align-items: center; + height: 1em; + color: $action-button-color; + + .status__media-icon, + .status__visibility-icon, + .status__reply-icon { + padding-left: 2px; + padding-right: 2px; + } + + .status__collapse-button.active > .fa-angle-double-up { + transform: rotate(-180deg); + } +} + +.no-reduce-motion .status__collapse-button { + &.activate { + & > .fa-angle-double-up { + animation: spring-flip-in 1s linear; + } + } + + &.deactivate { + & > .fa-angle-double-up { + animation: spring-flip-out 1s linear; + } + } +} + +.status__info__account { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.status-check-box { + border-bottom: 1px solid $ui-secondary-color; + display: flex; + + .status-check-box__status { + margin: 10px 0 10px 10px; + flex: 1; + overflow: hidden; + + .media-gallery { + max-width: 250px; + } + + .status__content { + padding: 0; + white-space: normal; + } + + .video-player, + .audio-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-top: -10px; + margin-bottom: 10px; + margin-left: 58px; + color: $dark-text-color; + padding: 8px 0; + padding-bottom: 2px; + 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; +} + +.status__action-bar-button { + margin-right: 18px; + + &.icon-button--with-counter { + margin-right: 14px; + } +} + +.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, + .audio-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; + } +} + +a.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 .button.logo-button { + margin-bottom: 15px; +} + +.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, + .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 { + position: relative; + 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, + .status-card__image-preview { + 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-card__image-preview { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: fill; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + +.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; + } + } +} + +.status__wrapper--filtered__button { + display: inline; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + font-size: inherit; + line-height: inherit; + + &:hover, + &:active { + text-decoration: underline; + } +} + +.notification, +.status { + position: relative; + + &.unread { + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + pointer-events: 0; + width: 100%; + height: 100%; + border-left: 2px solid $highlight-text-color; + pointer-events: none; + } + } +} + +.picture-in-picture { + position: fixed; + bottom: 20px; + right: 20px; + width: 300px; + + &.left { + right: unset; + left: 20px; + } + + &__footer { + border-radius: 0 0 4px 4px; + background: lighten($ui-base-color, 4%); + padding: 10px; + padding-top: 12px; + display: flex; + justify-content: space-between; + } + + &__header { + border-radius: 4px 4px 0 0; + background: lighten($ui-base-color, 4%); + padding: 10px; + display: flex; + justify-content: space-between; + + &__account { + display: flex; + text-decoration: none; + } + + .account__avatar { + margin-right: 10px; + } + + .display-name { + color: $primary-text-color; + text-decoration: none; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + span { + color: $darker-text-color; + } + } + } + + .video-player, + .audio-player { + border-radius: 0; + } +} + +.picture-in-picture-placeholder { + box-sizing: border-box; + border: 2px dashed lighten($ui-base-color, 8%); + background: $base-shadow-color; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 10px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + color: $darker-text-color; + + i { + display: block; + font-size: 24px; + font-weight: 400; + margin-bottom: 10px; + } + + &:hover, + &:focus, + &:active { + border-color: lighten($ui-base-color, 12%); + } +} diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss new file mode 100644 index 000000000..63374f3c3 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/containers.scss @@ -0,0 +1,909 @@ +.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; + + svg { + fill: $primary-text-color; + 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; + } + + @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; + } + } +} + +.grid-4 { + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-auto-columns: 25%; + grid-auto-rows: max-content; + + .column-0 { + grid-column: 1 / 5; + grid-row: 1; + } + + .column-1 { + grid-column: 1 / 4; + grid-row: 2; + } + + .column-2 { + grid-column: 4; + grid-row: 2; + } + + .column-3 { + grid-column: 2 / 5; + grid-row: 3; + } + + .column-4 { + grid-column: 1; + grid-row: 3; + } + + .landing-page__call-to-action { + min-height: 100%; + } + + .flash-message { + margin-bottom: 10px; + } + + @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: 5; + } + + .column-4 { + 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; + + svg { + display: block; + height: 18px; + width: auto; + position: relative; + bottom: -2px; + fill: $primary-text-color; + + @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; + } + } + } + + .directory__card { + border-radius: 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-radius: 0; + } + } + + .page-header { + @media screen and (max-width: $no-gap-breakpoint) { + border-bottom: 0; + } + } + + .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; + } + } + + .logo-button { + padding: 3px 15px; + } + + &__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 { + min-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; + } + } + + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + + .icon-button { + font-size: 18px; + } + } + + .directory__card { + margin-bottom: 0; + } + + .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..0f3a6cc6d --- /dev/null +++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss @@ -0,0 +1,86 @@ +// 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; + } +} + +.nothing-here { + color: $darker-text-color; +} + +.public-layout .public-account-header__tabs__tabs .counter.active::after { + border-bottom: 4px solid $ui-highlight-color; +} + +.composer { + .composer--spoiler input, + .compose-form__autosuggest-wrapper textarea { + &::placeholder { + color: $inverted-text-color; + } + } +} 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..c0944d417 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/dashboard.scss @@ -0,0 +1,78 @@ +.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; + box-sizing: border-box; + height: 100%; + } + + & > 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..00d290883 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/footer.scss @@ -0,0 +1,137 @@ +.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; + fill: lighten($ui-base-color, 34%); + } + + &:hover, + &:focus, + &:active { + svg { + 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..b93acd6cd --- /dev/null +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -0,0 +1,1042 @@ +$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 { + &.hidden { + display: none; + } + + .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; + } + } + + .recommended { + position: absolute; + margin: 0 4px; + margin-top: -2px; + } + } + } + + .row { + display: flex; + margin: 0 -5px; + + .input { + box-sizing: border-box; + flex: 1 1 auto; + width: 50%; + padding: 0 5px; + } + } + + .title { + color: #d9e1e8; + font-size: 20px; + line-height: 28px; + font-weight: 400; + margin-bottom: 30px; + } + + .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; + } + } + + .authentication-hint { + margin-bottom: 25px; + } + + .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; + } + } + + .input.datetime .label_input select { + display: inline-block; + width: auto; + flex: 0; + } + + .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%; + } + + .actions { + margin-top: 27px; + } + } + + .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; + } + } + + .fields-group.invited-by { + margin-bottom: 30px; + + .hint { + text-align: center; + } + } + } + + .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.static .label_input__wrapper { + font-size: 16px; + padding: 10px; + border: 1px solid $dark-text-color; + border-radius: 4px; + } + + input[type=text], + input[type=number], + input[type=email], + input[type=password], + input[type=url], + 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; + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &:invalid { + box-shadow: none; + } + + &: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[type=text], + input[type=number], + input[type=email], + input[type=password] { + &:focus:invalid:not(:placeholder-shown), + &:required:invalid:not(:placeholder-shown) { + border-color: lighten($error-red, 12%); + } + } + + .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; + } + + h4 { + margin-bottom: 15px !important; + } + + .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; + + &__blurred form { + filter: blur(2px); + } + + &__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); + border-radius: 4px; + margin-left: -4px; + margin-top: -4px; + padding: 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; + } + + &.warning { + border: 1px solid rgba($gold-star, 0.5); + background: rgba($gold-star, 0.25); + color: $gold-star; + } + + &.alert { + border: 1px solid rgba($error-value-color, 0.5); + background: rgba($error-value-color, 0.1); + color: $error-value-color; + } + + &.hidden { + display: none; + } + + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } + } + + &.warning a { + font-weight: 700; + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + color: inherit; + } + } + + p { + margin-bottom: 15px; + } + + .oauth-code { + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: 0; + 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; + } + + &.translation-prompt { + text-align: unset; + color: unset; + + a { + text-decoration: underline; + } + } +} + +.flash-message-stack { + margin-bottom: 30px; + + .flash-message { + border-radius: 0; + margin-bottom: 0; + border-top-width: 0; + + &:first-child { + border-radius: 4px 4px 0 0; + border-top-width: 1px; + } + + &:last-child { + border-radius: 0 0 4px 4px; + + &:first-child { + border-radius: 4px; + } + } + } +} + +.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; + } +} + +.input.user_confirm_password, +.input.user_website { + &:not(.field_with_errors) { + display: none; + } +} diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss new file mode 100644 index 000000000..af73feb89 --- /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 'statuses'; +@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..8f5309f2b --- /dev/null +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -0,0 +1,394 @@ +// 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%); + border-bottom-color: darken($ui-base-color, 12%); + + &.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, .compose-form__autosuggest-wrapper textarea { + color: lighten($ui-base-color, 80%); + + &:disabled { background: lighten($simple-background-color, 10%) } + + &::placeholder { + color: lighten($ui-base-color, 70%); + } + } + + .composer--options-wrapper { + background: lighten($ui-base-color, 10%); + } + + .composer--options > 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, +.favourite-modal, +.confirmation-modal, +.mute-modal, +.block-modal, +.report-modal, +.embed-modal, +.error-modal, +.onboarding-modal, +.report-modal__comment .setting-text__wrapper, +.report-modal__comment .setting-text { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.report-modal__comment { + border-right-color: lighten($ui-base-color, 8%); +} + +.report-modal__container { + border-top-color: lighten($ui-base-color, 8%); +} + +.boost-modal__action-bar, +.favourite-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.block-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 { + 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; +} + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + 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(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px; +} 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..7709d4535 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss @@ -0,0 +1,40 @@ +// 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; + +$success-green: lighten(#3c754d, 8%); + +$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-inversion: 'chains'; diff --git a/app/javascript/flavours/glitch/styles/modal.scss b/app/javascript/flavours/glitch/styles/modal.scss new file mode 100644 index 000000000..6c6de4206 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/modal.scss @@ -0,0 +1,35 @@ +.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; + + > div { + flex: 1; + max-height: 235px; + position: relative; + + img { + max-height: 100%; + max-width: 100%; + height: 100%; + position: absolute; + bottom: 0; + left: 0; + } + } +} + +@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..5fc41ed9e --- /dev/null +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -0,0 +1,283 @@ +.poll { + margin-top: 16px; + font-size: 14px; + + ul, + .e-content & ul { + margin: 0; + list-style: none; + } + + li { + margin-bottom: 10px; + position: relative; + } + + &__chart { + border-radius: 4px; + display: block; + background: darken($ui-primary-color, 5%); + height: 5px; + min-width: 1%; + + &.leading { + background: $ui-highlight-color; + } + } + + progress { + border: 0; + display: block; + width: 100%; + height: 5px; + appearance: none; + background: transparent; + + &::-webkit-progress-bar { + background: transparent; + } + + // Those rules need to be entirely separate or they won't work, hence the + // duplication + &::-moz-progress-bar { + border-radius: 4px; + background: darken($ui-primary-color, 5%); + } + + &::-ms-fill { + border-radius: 4px; + background: darken($ui-primary-color, 5%); + } + + &::-webkit-progress-value { + border-radius: 4px; + background: darken($ui-primary-color, 5%); + } + } + + &__option { + position: relative; + display: flex; + padding: 6px 0; + line-height: 18px; + cursor: default; + overflow: hidden; + + &__text { + display: inline-block; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: calc(100% - 45px - 25px); + } + + 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; + margin-top: auto; + margin-bottom: auto; + flex: 0 0 18px; + + &.checkbox { + border-radius: 4px; + } + + &.active { + border-color: $valid-value-color; + background: $valid-value-color; + } + + &:active, + &:focus, + &:hover { + border-color: lighten($valid-value-color, 15%); + border-width: 4px; + } + + &::-moz-focus-inner { + outline: 0 !important; + border: 0; + } + + &:focus, + &:active { + outline: 0 !important; + } + } + + &__number { + display: inline-block; + width: 45px; + font-weight: 700; + flex: 0 0 45px; + } + + &__voted { + padding: 0 5px; + display: inline-block; + + &__mark { + font-size: 18px; + } + } + + &__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 { + text-decoration: none; + } + + &:active, + &:focus { + background-color: rgba($dark-text-color, .1); + } + } + + .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%); + overflow-x: hidden; + + 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%; + + &:focus { + border-color: $highlight-text-color; + } + } + } + + .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__option { + 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..f6a90d271 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/rtl.scss @@ -0,0 +1,440 @@ +body.rtl { + direction: rtl; + + .column-header > button { + text-align: right; + padding-left: 0; + padding-right: 15px; + } + + .radio-button__input { + margin-right: 0; + margin-left: 10px; + } + + .directory__card__bar .display-name { + margin-left: 0; + margin-right: 15px; + } + + .display-name { + text-align: right; + } + + .notification__message { + margin-left: 0; + margin-right: 68px; + } + + .drawer__inner__mastodon > img { + transform: scaleX(-1); + } + + .notification__favourite-icon-wrapper { + left: auto; + right: -26px; + } + + .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; + } + + .composer--publisher { + text-align: left; + } + + .boost-modal__status-time, + .favourite-modal__status-time { + float: left; + } + + .navigation-bar__profile { + margin-left: 0; + margin-right: 8px; + } + + .search__input { + padding-right: 10px; + padding-left: 30px; + } + + .search__icon .fa { + right: auto; + left: 10px; + } + + .columns-area { + direction: rtl; + } + + .column-header__buttons { + left: 0; + right: auto; + margin-left: 0; + margin-right: -15px; + } + + .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 { + margin-left: 10px; + margin-right: 0; + + // Those are used for public pages + left: auto; + right: 10px; + } + + .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: 58px; + } + + .status__prepend-icon-wrapper { + left: auto; + right: -26px; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__header__tabs__buttons > .icon-button { + margin-right: 0; + margin-left: 8px; + } + + .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; + text-align: left; + } + + .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-name .display-name { + text-align: right; + } + + .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: 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; + } + } + } + + .columns-area--mobile .column, + .columns-area--mobile .drawer { + padding-left: 0; + padding-right: 0; + } + + .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/statuses.scss b/app/javascript/flavours/glitch/styles/statuses.scss new file mode 100644 index 000000000..b807fa45a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/statuses.scss @@ -0,0 +1,281 @@ +.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: 1.2; + height: auto; + min-height: 36px; + min-width: 88px; + white-space: normal; + overflow-wrap: break-word; + hyphens: auto; + padding: 0 15px; + border: 0; + + svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-right: 5px; + fill: $primary-text-color; + } + + &:active, + &:focus, + &:hover { + background: lighten($ui-highlight-color, 10%); + } + + &:disabled, + &.disabled { + &:active, + &:focus, + &:hover { + background: $ui-primary-color; + } + } + + &.button--destructive { + &:active, + &:focus, + &:hover { + background: $error-red; + } + } + + @media screen and (max-width: $no-gap-breakpoint) { + svg { + display: none; + } + } +} + +a.button.logo-button { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.embed, +.public-layout { + .status__content[data-spoiler=folded] { + .e-content { + display: none; + } + + p:first-child { + margin-bottom: 0; + } + } + + .detailed-status { + padding: 15px; + + .detailed-status__display-avatar .account__avatar { + width: 48px; + height: 48px; + } + } + + .status { + padding: 15px 15px 15px (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + left: 15px; + top: 17px; + + .account__avatar { + width: 48px; + height: 48px; + } + } + + &__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; + } + + &__action-bar-button { + font-size: 18px; + width: 23.1429px; + height: 23.1429px; + line-height: 23.15px; + } + } +} + +// 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; + padding-bottom: 1px; + } + + .status__visibility-icon { + padding: 0 4px; + } + + .status__info .status__display-name { + display: block; + max-width: 100%; + padding: 6px 0; + padding-right: 25px; + margin: initial; + } + + .status__avatar { + height: 48px; + position: absolute; + width: 48px; + margin: initial; + } + } +} + +.rtl { + .embed, + .public-layout { + .status { + padding-left: 10px; + padding-right: 68px; + + .status__info .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .status__relative-time, + .status__visibility-icon { + float: left; + } + } + } +} + +.status__content__read-more-button { + display: block; + font-size: 15px; + line-height: 20px; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + padding-top: 8px; + text-decoration: none; + + &:hover, + &:active { + text-decoration: underline; + } +} diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss new file mode 100644 index 000000000..ec2ee7c1c --- /dev/null +++ b/app/javascript/flavours/glitch/styles/tables.scss @@ -0,0 +1,289 @@ +.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; + } + } + } + + &__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; + } + } + + &__form { + padding: 16px; + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + background: $ui-base-color; + + .fields-row { + padding-top: 0; + margin-bottom: 0; + } + } + + &__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) { + .optional &: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; + } + + &--with-image { + display: flex; + align-items: center; + } + + &__image { + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + + .emojione { + width: 32px; + height: 32px; + } + } + + &__text { + flex: 1 1 auto; + } + + &__extra { + flex: 0 0 auto; + text-align: right; + color: $darker-text-color; + font-weight: 500; + } + } + + .directory__tag { + margin: 0; + width: 100%; + + a { + background: transparent; + border-radius: 0; + } + } + } + + &.optional .batch-table__toolbar, + &.optional .batch-table__row__select { + @media screen and (max-width: $no-gap-breakpoint) { + display: none; + } + } + + .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..6e242281b --- /dev/null +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -0,0 +1,64 @@ +// 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; +$passive-text-color: $gold-star !default; +$active-passive-text-color: $success-green !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..05bc076ea --- /dev/null +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -0,0 +1,609 @@ +.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; + } + } + + .trends__item { + padding: 10px; + } +} + +.trends-widget { + h4 { + color: $darker-text-color; + } +} + +.box-widget { + padding: 20px; + border-radius: 4px; + background: $ui-base-color; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); +} + +.placeholder-widget { + padding: 16px; + border-radius: 4px; + border: 2px dashed $dark-text-color; + text-align: center; + color: $darker-text-color; + margin-bottom: 10px; +} + +.contact-widget { + min-height: 100%; + font-size: 15px; + color: $darker-text-color; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + padding: 0; + + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; + } + + .account { + border-bottom: 0; + padding: 10px 0; + padding-top: 5px; + } + + & > a { + display: inline-block; + padding: 10px; + padding-top: 0; + color: $darker-text-color; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + +.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; + } + + &__interrelationships { + width: 21px; + } + + .fa { + font-size: 16px; + + &.active { + color: $highlight-text-color; + } + + &.passive { + color: $passive-text-color; + } + + &.active.passive { + color: $active-passive-text-color; + } + } + + @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; + } +} + +.notice-widget, +.placeholder-widget { + a { + text-decoration: none; + font-weight: 500; + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + +.table-of-contents { + background: darken($ui-base-color, 4%); + min-height: 100%; + font-size: 14px; + border-radius: 4px; + + li a { + display: block; + font-weight: 500; + padding: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + color: $primary-text-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + li:last-child a { + border-bottom: 0; + } + + li ul { + padding-left: 20px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + } +} diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml new file mode 100644 index 000000000..2a98e4c29 --- /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: packs/public.js + 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: packs/settings.js + 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..a6c6ab0ab --- /dev/null +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -0,0 +1,175 @@ +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 BlockModal () { + return import(/* webpackChunkName: "flavours/glitch/async/block_modal" */'flavours/glitch/features/ui/components/block_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 Audio () { + return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio'); +} + +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'); +} + +export function Search () { + return import(/*webpackChunkName: "features/glitch/async/search" */'flavours/glitch/features/search'); +} + +export function Tesseract () { + return import(/*webpackChunkName: "tesseract" */'tesseract.js'); +} + +export function Directory () { + return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory'); +} + +export function FollowRecommendations () { + return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations'); +} 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..0fb378cc1 --- /dev/null +++ b/app/javascript/flavours/glitch/util/backend_links.js @@ -0,0 +1,9 @@ +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}`; +export const filterEditLink = (id) => `/filters/${id}/edit`; +export const relationshipsLink = '/relationships'; +export const securityLink = '/auth/edit'; 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..4b8123dba --- /dev/null +++ b/app/javascript/flavours/glitch/util/base_polyfills.js @@ -0,0 +1,47 @@ +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'; +import promiseFinally from 'promise.prototype.finally'; + +if (!Array.prototype.includes) { + includes.shim(); +} + +if (!Object.assign) { + Object.assign = assign; +} + +if (!Object.values) { + values.shim(); +} + +if (!Number.isNaN) { + Number.isNaN = isNaN; +} + +promiseFinally.shim(); + +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..66cf51c4b --- /dev/null +++ b/app/javascript/flavours/glitch/util/compare_id.js @@ -0,0 +1,11 @@ +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/config.js b/app/javascript/flavours/glitch/util/config.js new file mode 100644 index 000000000..c3e2b73ae --- /dev/null +++ b/app/javascript/flavours/glitch/util/config.js @@ -0,0 +1,10 @@ +import ready from './ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); 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..7aa9e87b1 --- /dev/null +++ b/app/javascript/flavours/glitch/util/counter.js @@ -0,0 +1,9 @@ +import { urlRegex } from './url_regex'; + +const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx'; + +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..d94aeb9d4 --- /dev/null +++ b/app/javascript/flavours/glitch/util/dom_helpers.js @@ -0,0 +1,14 @@ +// Package imports. +import { supportsPassiveEvents } from 'detect-passive-events'; + +// This will either be a passive lister options object (if passive +// events are supported), or `false`. +export const withPassive = supportsPassiveEvents ? { 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..d54ef154a --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/emoji_map.json @@ -0,0 +1 @@ +{"😀":"1f600","😃":"1f603","😄":"1f604","😁":"1f601","😆":"1f606","😅":"1f605","🤣":"1f923","😂":"1f602","🙂":"1f642","🙃":"1f643","😉":"1f609","😊":"1f60a","😇":"1f607","🥰":"1f970","😍":"1f60d","🤩":"1f929","😘":"1f618","😗":"1f617","☺":"263a","😚":"1f61a","😙":"1f619","😋":"1f60b","😛":"1f61b","😜":"1f61c","🤪":"1f92a","😝":"1f61d","🤑":"1f911","🤗":"1f917","🤭":"1f92d","🤫":"1f92b","🤔":"1f914","🤐":"1f910","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","😏":"1f60f","😒":"1f612","🙄":"1f644","😬":"1f62c","🤥":"1f925","😌":"1f60c","😔":"1f614","😪":"1f62a","🤤":"1f924","😴":"1f634","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","🥵":"1f975","🥶":"1f976","🥴":"1f974","😵":"1f635","🤯":"1f92f","🤠":"1f920","🥳":"1f973","😎":"1f60e","🤓":"1f913","🧐":"1f9d0","😕":"1f615","😟":"1f61f","🙁":"1f641","☹":"2639","😮":"1f62e","😯":"1f62f","😲":"1f632","😳":"1f633","🥺":"1f97a","😦":"1f626","😧":"1f627","😨":"1f628","😰":"1f630","😥":"1f625","😢":"1f622","😭":"1f62d","😱":"1f631","😖":"1f616","😣":"1f623","😞":"1f61e","😓":"1f613","😩":"1f629","😫":"1f62b","🥱":"1f971","😤":"1f624","😡":"1f621","😠":"1f620","🤬":"1f92c","😈":"1f608","👿":"1f47f","💀":"1f480","☠":"2620","💩":"1f4a9","🤡":"1f921","👹":"1f479","👺":"1f47a","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","💋":"1f48b","💌":"1f48c","💘":"1f498","💝":"1f49d","💖":"1f496","💗":"1f497","💓":"1f493","💞":"1f49e","💕":"1f495","💟":"1f49f","❣":"2763","💔":"1f494","❤":"2764","🧡":"1f9e1","💛":"1f49b","💚":"1f49a","💙":"1f499","💜":"1f49c","🤎":"1f90e","🖤":"1f5a4","🤍":"1f90d","💯":"1f4af","💢":"1f4a2","💥":"1f4a5","💫":"1f4ab","💦":"1f4a6","💨":"1f4a8","🕳":"1f573","💣":"1f4a3","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","💤":"1f4a4","👋":"1f44b","🤚":"1f91a","🖐":"1f590","✋":"270b","🖖":"1f596","👌":"1f44c","🤏":"1f90f","✌":"270c","🤞":"1f91e","🤟":"1f91f","🤘":"1f918","🤙":"1f919","👈":"1f448","👉":"1f449","👆":"1f446","🖕":"1f595","👇":"1f447","☝":"261d","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","👏":"1f44f","🙌":"1f64c","👐":"1f450","🤲":"1f932","🤝":"1f91d","🙏":"1f64f","✍":"270d","💅":"1f485","🤳":"1f933","💪":"1f4aa","🦾":"1f9be","🦿":"1f9bf","🦵":"1f9b5","🦶":"1f9b6","👂":"1f442","🦻":"1f9bb","👃":"1f443","🧠":"1f9e0","🦷":"1f9b7","🦴":"1f9b4","👀":"1f440","👁":"1f441","👅":"1f445","👄":"1f444","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👱":"1f471","👨":"1f468","🧔":"1f9d4","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🧏":"1f9cf","🙇":"1f647","🤦":"1f926","🤷":"1f937","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🦸":"1f9b8","🦹":"1f9b9","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","💆":"1f486","💇":"1f487","🚶":"1f6b6","🧍":"1f9cd","🧎":"1f9ce","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","🕴":"1f574","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","👭":"1f46d","👫":"1f46b","👬":"1f46c","💏":"1f48f","💑":"1f491","👪":"1f46a","🗣":"1f5e3","👤":"1f464","👥":"1f465","👣":"1f463","🏻":"1f463","🏼":"1f463","🏽":"1f463","🏾":"1f463","🏿":"1f463","🦰":"1f463","🦱":"1f463","🦳":"1f463","🦲":"1f463","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🦧":"1f9a7","🐶":"1f436","🐕":"1f415","🦮":"1f9ae","🐩":"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","🦥":"1f9a5","🦦":"1f9a6","🦨":"1f9a8","🦘":"1f998","🦡":"1f9a1","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦢":"1f9a2","🦉":"1f989","🦩":"1f9a9","🦚":"1f99a","🦜":"1f99c","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🐌":"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","🧄":"1f9c4","🧅":"1f9c5","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥯":"1f96f","🥞":"1f95e","🧇":"1f9c7","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🧆":"1f9c6","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🧈":"1f9c8","🧂":"1f9c2","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🥮":"1f96e","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🦀":"1f980","🦞":"1f99e","🦐":"1f990","🦑":"1f991","🦪":"1f9aa","🍦":"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","🧃":"1f9c3","🧉":"1f9c9","🧊":"1f9ca","🥢":"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","🛕":"1f6d5","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🏙":"1f3d9","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🎠":"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","🏎":"1f3ce","🏍":"1f3cd","🛵":"1f6f5","🦽":"1f9bd","🦼":"1f9bc","🛺":"1f6fa","🚲":"1f6b2","🛴":"1f6f4","🛹":"1f6f9","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","🛢":"1f6e2","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🛑":"1f6d1","🚧":"1f6a7","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","🪂":"1fa82","💺":"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","🪐":"1fa90","⭐":"2b50","🌟":"1f31f","🌠":"1f320","🌌":"1f30c","☁":"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","🤿":"1f93f","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎯":"1f3af","🪀":"1fa80","🪁":"1fa81","🎱":"1f3b1","🔮":"1f52e","🧿":"1f9ff","🎮":"1f3ae","🕹":"1f579","🎰":"1f3b0","🎲":"1f3b2","🧩":"1f9e9","🧸":"1f9f8","♠":"2660","♥":"2665","♦":"2666","♣":"2663","♟":"265f","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🧵":"1f9f5","🧶":"1f9f6","👓":"1f453","🕶":"1f576","🥽":"1f97d","🥼":"1f97c","🦺":"1f9ba","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","🥻":"1f97b","🩱":"1fa71","🩲":"1fa72","🩳":"1fa73","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","🥾":"1f97e","🥿":"1f97f","👠":"1f460","👡":"1f461","🩰":"1fa70","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🪕":"1fa95","🥁":"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","🪔":"1fa94","📔":"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","🪓":"1fa93","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚖":"2696","🦯":"1f9af","🔗":"1f517","⛓":"26d3","🧰":"1f9f0","🧲":"1f9f2","⚗":"2697","🧪":"1f9ea","🧫":"1f9eb","🧬":"1f9ec","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","💉":"1f489","🩸":"1fa78","💊":"1f48a","🩹":"1fa79","🩺":"1fa7a","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🪑":"1fa91","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","🪒":"1fa92","🧴":"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","🔠":"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","🔴":"1f534","🟠":"1f7e0","🟡":"1f7e1","🟢":"1f7e2","🔵":"1f535","🟣":"1f7e3","🟤":"1f7e4","⚫":"26ab","⚪":"26aa","🟥":"1f7e5","🟧":"1f7e7","🟨":"1f7e8","🟩":"1f7e9","🟦":"1f7e6","🟪":"1f7ea","🟫":"1f7eb","⬛":"2b1b","⬜":"2b1c","◼":"25fc","◻":"25fb","◾":"25fe","◽":"25fd","▪":"25aa","▫":"25ab","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔳":"1f533","🔲":"1f532","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","❣️":"2763","❤️":"2764","🕳️":"1f573","🗨️":"1f5e8","🗯️":"1f5ef","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","🤏🏻":"1f90f-1f3fb","🤏🏼":"1f90f-1f3fc","🤏🏽":"1f90f-1f3fd","🤏🏾":"1f90f-1f3fe","🤏🏿":"1f90f-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-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","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-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","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-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","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","🦻🏻":"1f9bb-1f3fb","🦻🏼":"1f9bb-1f3fc","🦻🏽":"1f9bb-1f3fd","🦻🏾":"1f9bb-1f3fe","🦻🏿":"1f9bb-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","👶🏻":"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","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-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","🙍🏻":"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","🧏🏻":"1f9cf-1f3fb","🧏🏼":"1f9cf-1f3fc","🧏🏽":"1f9cf-1f3fd","🧏🏾":"1f9cf-1f3fe","🧏🏿":"1f9cf-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","👮🏻":"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","🤵🏻":"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","💆🏻":"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","🧍🏻":"1f9cd-1f3fb","🧍🏼":"1f9cd-1f3fc","🧍🏽":"1f9cd-1f3fd","🧍🏾":"1f9cd-1f3fe","🧍🏿":"1f9cd-1f3ff","🧎🏻":"1f9ce-1f3fb","🧎🏼":"1f9ce-1f3fc","🧎🏽":"1f9ce-1f3fd","🧎🏾":"1f9ce-1f3fe","🧎🏿":"1f9ce-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","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🏇🏻":"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","🤸🏻":"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","🧘🏻":"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","👭🏻":"1f46d-1f3fb","👭🏼":"1f46d-1f3fc","👭🏽":"1f46d-1f3fd","👭🏾":"1f46d-1f3fe","👭🏿":"1f46d-1f3ff","👫🏻":"1f46b-1f3fb","👫🏼":"1f46b-1f3fc","👫🏽":"1f46b-1f3fd","👫🏾":"1f46b-1f3fe","👫🏿":"1f46b-1f3ff","👬🏻":"1f46c-1f3fb","👬🏼":"1f46c-1f3fc","👬🏽":"1f46c-1f3fd","👬🏾":"1f46c-1f3fe","👬🏿":"1f46c-1f3ff","🗣️":"1f5e3","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏚️":"1f3da","⛩️":"26e9","🏙️":"1f3d9","♨️":"2668","🏎️":"1f3ce","🏍️":"1f3cd","🛣️":"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","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🎙️":"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","◼️":"25fc","◻️":"25fb","▪️":"25aa","▫️":"25ab","🏳️":"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","👁🗨":"1f441-200d-1f5e8","👱♂":"1f471-200d-2642-fe0f","👨🦰":"1f468-200d-1f9b0","👨🦱":"1f468-200d-1f9b1","👨🦳":"1f468-200d-1f9b3","👨🦲":"1f468-200d-1f9b2","👱♀":"1f471-200d-2640-fe0f","👩🦰":"1f469-200d-1f9b0","👩🦱":"1f469-200d-1f9b1","👩🦳":"1f469-200d-1f9b3","👩🦲":"1f469-200d-1f9b2","🙍♂":"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","🧏♂":"1f9cf-200d-2642-fe0f","🧏♀":"1f9cf-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","👨⚕":"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","🦸♂":"1f9b8-200d-2642-fe0f","🦸♀":"1f9b8-200d-2640-fe0f","🦹♂":"1f9b9-200d-2642-fe0f","🦹♀":"1f9b9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🧟♀":"1f9df-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","🧍♂":"1f9cd-200d-2642-fe0f","🧍♀":"1f9cd-200d-2640-fe0f","🧎♂":"1f9ce-200d-2642-fe0f","🧎♀":"1f9ce-200d-2640-fe0f","👨🦯":"1f468-200d-1f9af","👩🦯":"1f469-200d-1f9af","👨🦼":"1f468-200d-1f9bc","👩🦼":"1f469-200d-1f9bc","👨🦽":"1f468-200d-1f9bd","👩🦽":"1f469-200d-1f9bd","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-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","🧘♂":"1f9d8-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","🐕🦺":"1f415-200d-1f9ba","#️⃣":"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","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","👱♂️":"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","👨🏻🦰":"1f468-1f3fb-200d-1f9b0","👨🏼🦰":"1f468-1f3fc-200d-1f9b0","👨🏽🦰":"1f468-1f3fd-200d-1f9b0","👨🏾🦰":"1f468-1f3fe-200d-1f9b0","👨🏿🦰":"1f468-1f3ff-200d-1f9b0","👨🏻🦱":"1f468-1f3fb-200d-1f9b1","👨🏼🦱":"1f468-1f3fc-200d-1f9b1","👨🏽🦱":"1f468-1f3fd-200d-1f9b1","👨🏾🦱":"1f468-1f3fe-200d-1f9b1","👨🏿🦱":"1f468-1f3ff-200d-1f9b1","👨🏻🦳":"1f468-1f3fb-200d-1f9b3","👨🏼🦳":"1f468-1f3fc-200d-1f9b3","👨🏽🦳":"1f468-1f3fd-200d-1f9b3","👨🏾🦳":"1f468-1f3fe-200d-1f9b3","👨🏿🦳":"1f468-1f3ff-200d-1f9b3","👨🏻🦲":"1f468-1f3fb-200d-1f9b2","👨🏼🦲":"1f468-1f3fc-200d-1f9b2","👨🏽🦲":"1f468-1f3fd-200d-1f9b2","👨🏾🦲":"1f468-1f3fe-200d-1f9b2","👨🏿🦲":"1f468-1f3ff-200d-1f9b2","👱♀️":"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","👩🏻🦰":"1f469-1f3fb-200d-1f9b0","👩🏼🦰":"1f469-1f3fc-200d-1f9b0","👩🏽🦰":"1f469-1f3fd-200d-1f9b0","👩🏾🦰":"1f469-1f3fe-200d-1f9b0","👩🏿🦰":"1f469-1f3ff-200d-1f9b0","👩🏻🦱":"1f469-1f3fb-200d-1f9b1","👩🏼🦱":"1f469-1f3fc-200d-1f9b1","👩🏽🦱":"1f469-1f3fd-200d-1f9b1","👩🏾🦱":"1f469-1f3fe-200d-1f9b1","👩🏿🦱":"1f469-1f3ff-200d-1f9b1","👩🏻🦳":"1f469-1f3fb-200d-1f9b3","👩🏼🦳":"1f469-1f3fc-200d-1f9b3","👩🏽🦳":"1f469-1f3fd-200d-1f9b3","👩🏾🦳":"1f469-1f3fe-200d-1f9b3","👩🏿🦳":"1f469-1f3ff-200d-1f9b3","👩🏻🦲":"1f469-1f3fb-200d-1f9b2","👩🏼🦲":"1f469-1f3fc-200d-1f9b2","👩🏽🦲":"1f469-1f3fd-200d-1f9b2","👩🏾🦲":"1f469-1f3fe-200d-1f9b2","👩🏿🦲":"1f469-1f3ff-200d-1f9b2","🙍♂️":"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","🧏♂️":"1f9cf-200d-2642-fe0f","🧏🏻♂":"1f9cf-1f3fb-200d-2642-fe0f","🧏🏼♂":"1f9cf-1f3fc-200d-2642-fe0f","🧏🏽♂":"1f9cf-1f3fd-200d-2642-fe0f","🧏🏾♂":"1f9cf-1f3fe-200d-2642-fe0f","🧏🏿♂":"1f9cf-1f3ff-200d-2642-fe0f","🧏♀️":"1f9cf-200d-2640-fe0f","🧏🏻♀":"1f9cf-1f3fb-200d-2640-fe0f","🧏🏼♀":"1f9cf-1f3fc-200d-2640-fe0f","🧏🏽♀":"1f9cf-1f3fd-200d-2640-fe0f","🧏🏾♀":"1f9cf-1f3fe-200d-2640-fe0f","🧏🏿♀":"1f9cf-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","👨⚕️":"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","🦸♂️":"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","🦸♀️":"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","🦹♂️":"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","🦹♀️":"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","🧙♂️":"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","🧙♀️":"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","🧚♂️":"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","🧚♀️":"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","🧛♂️":"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","🧛♀️":"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","🧜♂️":"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","🧜♀️":"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","🧝♂️":"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","🧝♀️":"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","🧞♂️":"1f9de-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🧟♀️":"1f9df-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","🧍♂️":"1f9cd-200d-2642-fe0f","🧍🏻♂":"1f9cd-1f3fb-200d-2642-fe0f","🧍🏼♂":"1f9cd-1f3fc-200d-2642-fe0f","🧍🏽♂":"1f9cd-1f3fd-200d-2642-fe0f","🧍🏾♂":"1f9cd-1f3fe-200d-2642-fe0f","🧍🏿♂":"1f9cd-1f3ff-200d-2642-fe0f","🧍♀️":"1f9cd-200d-2640-fe0f","🧍🏻♀":"1f9cd-1f3fb-200d-2640-fe0f","🧍🏼♀":"1f9cd-1f3fc-200d-2640-fe0f","🧍🏽♀":"1f9cd-1f3fd-200d-2640-fe0f","🧍🏾♀":"1f9cd-1f3fe-200d-2640-fe0f","🧍🏿♀":"1f9cd-1f3ff-200d-2640-fe0f","🧎♂️":"1f9ce-200d-2642-fe0f","🧎🏻♂":"1f9ce-1f3fb-200d-2642-fe0f","🧎🏼♂":"1f9ce-1f3fc-200d-2642-fe0f","🧎🏽♂":"1f9ce-1f3fd-200d-2642-fe0f","🧎🏾♂":"1f9ce-1f3fe-200d-2642-fe0f","🧎🏿♂":"1f9ce-1f3ff-200d-2642-fe0f","🧎♀️":"1f9ce-200d-2640-fe0f","🧎🏻♀":"1f9ce-1f3fb-200d-2640-fe0f","🧎🏼♀":"1f9ce-1f3fc-200d-2640-fe0f","🧎🏽♀":"1f9ce-1f3fd-200d-2640-fe0f","🧎🏾♀":"1f9ce-1f3fe-200d-2640-fe0f","🧎🏿♀":"1f9ce-1f3ff-200d-2640-fe0f","👨🏻🦯":"1f468-1f3fb-200d-1f9af","👨🏼🦯":"1f468-1f3fc-200d-1f9af","👨🏽🦯":"1f468-1f3fd-200d-1f9af","👨🏾🦯":"1f468-1f3fe-200d-1f9af","👨🏿🦯":"1f468-1f3ff-200d-1f9af","👩🏻🦯":"1f469-1f3fb-200d-1f9af","👩🏼🦯":"1f469-1f3fc-200d-1f9af","👩🏽🦯":"1f469-1f3fd-200d-1f9af","👩🏾🦯":"1f469-1f3fe-200d-1f9af","👩🏿🦯":"1f469-1f3ff-200d-1f9af","👨🏻🦼":"1f468-1f3fb-200d-1f9bc","👨🏼🦼":"1f468-1f3fc-200d-1f9bc","👨🏽🦼":"1f468-1f3fd-200d-1f9bc","👨🏾🦼":"1f468-1f3fe-200d-1f9bc","👨🏿🦼":"1f468-1f3ff-200d-1f9bc","👩🏻🦼":"1f469-1f3fb-200d-1f9bc","👩🏼🦼":"1f469-1f3fc-200d-1f9bc","👩🏽🦼":"1f469-1f3fd-200d-1f9bc","👩🏾🦼":"1f469-1f3fe-200d-1f9bc","👩🏿🦼":"1f469-1f3ff-200d-1f9bc","👨🏻🦽":"1f468-1f3fb-200d-1f9bd","👨🏼🦽":"1f468-1f3fc-200d-1f9bd","👨🏽🦽":"1f468-1f3fd-200d-1f9bd","👨🏾🦽":"1f468-1f3fe-200d-1f9bd","👨🏿🦽":"1f468-1f3ff-200d-1f9bd","👩🏻🦽":"1f469-1f3fb-200d-1f9bd","👩🏼🦽":"1f469-1f3fc-200d-1f9bd","👩🏽🦽":"1f469-1f3fd-200d-1f9bd","👩🏾🦽":"1f469-1f3fe-200d-1f9bd","👩🏿🦽":"1f469-1f3ff-200d-1f9bd","🏃♂️":"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-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","🧖♀️":"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","🧗♂️":"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","🧗♀️":"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","🏌♂️":"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","🧘♂️":"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","🧘♀️":"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","🏳️🌈":"1f3f3-fe0f-200d-1f308","🏴☠️":"1f3f4-200d-2620-fe0f","👁️🗨️":"1f441-200d-1f5e8","👱🏻♂️":"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","🙍🏻♂️":"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","🧏🏻♂️":"1f9cf-1f3fb-200d-2642-fe0f","🧏🏼♂️":"1f9cf-1f3fc-200d-2642-fe0f","🧏🏽♂️":"1f9cf-1f3fd-200d-2642-fe0f","🧏🏾♂️":"1f9cf-1f3fe-200d-2642-fe0f","🧏🏿♂️":"1f9cf-1f3ff-200d-2642-fe0f","🧏🏻♀️":"1f9cf-1f3fb-200d-2640-fe0f","🧏🏼♀️":"1f9cf-1f3fc-200d-2640-fe0f","🧏🏽♀️":"1f9cf-1f3fd-200d-2640-fe0f","🧏🏾♀️":"1f9cf-1f3fe-200d-2640-fe0f","🧏🏿♀️":"1f9cf-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","👨🏻⚕️":"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","🦸🏻♂️":"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","🦸🏻♀️":"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","🦹🏻♂️":"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","🦹🏻♀️":"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","🧙🏻♂️":"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","🧙🏻♀️":"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","🧚🏻♂️":"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","🧚🏻♀️":"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","🧛🏻♂️":"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","🧛🏻♀️":"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","🧜🏻♂️":"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","🧜🏻♀️":"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","🧝🏻♂️":"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","🧝🏻♀️":"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","💆🏻♂️":"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","🧍🏻♂️":"1f9cd-1f3fb-200d-2642-fe0f","🧍🏼♂️":"1f9cd-1f3fc-200d-2642-fe0f","🧍🏽♂️":"1f9cd-1f3fd-200d-2642-fe0f","🧍🏾♂️":"1f9cd-1f3fe-200d-2642-fe0f","🧍🏿♂️":"1f9cd-1f3ff-200d-2642-fe0f","🧍🏻♀️":"1f9cd-1f3fb-200d-2640-fe0f","🧍🏼♀️":"1f9cd-1f3fc-200d-2640-fe0f","🧍🏽♀️":"1f9cd-1f3fd-200d-2640-fe0f","🧍🏾♀️":"1f9cd-1f3fe-200d-2640-fe0f","🧍🏿♀️":"1f9cd-1f3ff-200d-2640-fe0f","🧎🏻♂️":"1f9ce-1f3fb-200d-2642-fe0f","🧎🏼♂️":"1f9ce-1f3fc-200d-2642-fe0f","🧎🏽♂️":"1f9ce-1f3fd-200d-2642-fe0f","🧎🏾♂️":"1f9ce-1f3fe-200d-2642-fe0f","🧎🏿♂️":"1f9ce-1f3ff-200d-2642-fe0f","🧎🏻♀️":"1f9ce-1f3fb-200d-2640-fe0f","🧎🏼♀️":"1f9ce-1f3fc-200d-2640-fe0f","🧎🏽♀️":"1f9ce-1f3fd-200d-2640-fe0f","🧎🏾♀️":"1f9ce-1f3fe-200d-2640-fe0f","🧎🏿♀️":"1f9ce-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-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-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","🧗🏻♂️":"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","🧗🏻♀️":"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","🏌️♂️":"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","🧘🏻♂️":"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","🧘🏻♀️":"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","🧑🤝🧑":"1f9d1-200d-1f91d-200d-1f9d1","👩❤👨":"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","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","🧑🏻🤝🧑🏻":"1f9d1-1f3fb-200d-1f91d-200d-1f9d1-1f3fb","🧑🏼🤝🧑🏻":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fb","🧑🏼🤝🧑🏼":"1f9d1-1f3fc-200d-1f91d-200d-1f9d1-1f3fc","🧑🏽🤝🧑🏻":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fb","🧑🏽🤝🧑🏼":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fc","🧑🏽🤝🧑🏽":"1f9d1-1f3fd-200d-1f91d-200d-1f9d1-1f3fd","🧑🏾🤝🧑🏻":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fb","🧑🏾🤝🧑🏼":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fc","🧑🏾🤝🧑🏽":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fd","🧑🏾🤝🧑🏾":"1f9d1-1f3fe-200d-1f91d-200d-1f9d1-1f3fe","🧑🏿🤝🧑🏻":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fb","🧑🏿🤝🧑🏼":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fc","🧑🏿🤝🧑🏽":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fd","🧑🏿🤝🧑🏾":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3fe","🧑🏿🤝🧑🏿":"1f9d1-1f3ff-200d-1f91d-200d-1f9d1-1f3ff","👩🏼🤝👩🏻":"1f469-1f3fc-200d-1f91d-200d-1f469-1f3fb","👩🏽🤝👩🏻":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fb","👩🏽🤝👩🏼":"1f469-1f3fd-200d-1f91d-200d-1f469-1f3fc","👩🏾🤝👩🏻":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fb","👩🏾🤝👩🏼":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fc","👩🏾🤝👩🏽":"1f469-1f3fe-200d-1f91d-200d-1f469-1f3fd","👩🏿🤝👩🏻":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fb","👩🏿🤝👩🏼":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fc","👩🏿🤝👩🏽":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fd","👩🏿🤝👩🏾":"1f469-1f3ff-200d-1f91d-200d-1f469-1f3fe","👩🏻🤝👨🏼":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fc","👩🏻🤝👨🏽":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fd","👩🏻🤝👨🏾":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fe","👩🏻🤝👨🏿":"1f469-1f3fb-200d-1f91d-200d-1f468-1f3ff","👩🏼🤝👨🏻":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fb","👩🏼🤝👨🏽":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fd","👩🏼🤝👨🏾":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fe","👩🏼🤝👨🏿":"1f469-1f3fc-200d-1f91d-200d-1f468-1f3ff","👩🏽🤝👨🏻":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fb","👩🏽🤝👨🏼":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fc","👩🏽🤝👨🏾":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fe","👩🏽🤝👨🏿":"1f469-1f3fd-200d-1f91d-200d-1f468-1f3ff","👩🏾🤝👨🏻":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fb","👩🏾🤝👨🏼":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fc","👩🏾🤝👨🏽":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fd","👩🏾🤝👨🏿":"1f469-1f3fe-200d-1f91d-200d-1f468-1f3ff","👩🏿🤝👨🏻":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fb","👩🏿🤝👨🏼":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fc","👩🏿🤝👨🏽":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fd","👩🏿🤝👨🏾":"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fe","👨🏼🤝👨🏻":"1f468-1f3fc-200d-1f91d-200d-1f468-1f3fb","👨🏽🤝👨🏻":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fb","👨🏽🤝👨🏼":"1f468-1f3fd-200d-1f91d-200d-1f468-1f3fc","👨🏾🤝👨🏻":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fb","👨🏾🤝👨🏼":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fc","👨🏾🤝👨🏽":"1f468-1f3fe-200d-1f91d-200d-1f468-1f3fd","👨🏿🤝👨🏻":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fb","👨🏿🤝👨🏼":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fc","👨🏿🤝👨🏽":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fd","👨🏿🤝👨🏾":"1f468-1f3ff-200d-1f91d-200d-1f468-1f3fe","👩❤💋👨":"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..e4519a13e --- /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..162946bbb --- /dev/null +++ b/app/javascript/flavours/glitch/util/emoji/index.js @@ -0,0 +1,116 @@ +import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; +import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'flavours/glitch/util/config'; +import Trie from 'substring-trie'; + +const trie = new Trie(Object.keys(unicodeMapping)); + +// Convert to file names from emojis. (For different variation selector emojis) +const emojiFilenames = (emojis) => { + return emojis.map(v => unicodeMapping[v].filename); +}; + +// Emoji requiring extra borders depending on theme +const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲']); +const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); + +const emojiFilename = (filename) => { + const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji; + return borderedEmoji.includes(filename) ? (filename + '_border') : filename; +}; + +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 || useSystemEmojiFont || !(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 custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`; + 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 if (!useSystemEmojiFont) { // 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/${emojiFilename(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, + customCategory: emoji.get('category'), + }); + }); + + return emojis; +}; + +export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom'])); 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/idna.js b/app/javascript/flavours/glitch/util/idna.js new file mode 100644 index 000000000..efab5bacf --- /dev/null +++ b/app/javascript/flavours/glitch/util/idna.js @@ -0,0 +1,10 @@ +import punycode from 'punycode'; + +const IDNA_PREFIX = 'xn--'; + +export const decode = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; 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..911468e6f --- /dev/null +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -0,0 +1,38 @@ +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 profile_directory = getMeta('profile_directory'); +export const isStaff = getMeta('is_staff'); +export const defaultContentType = getMeta('default_content_type'); +export const forceSingleColumn = getMeta('advanced_layout') === false; +export const useBlurhash = getMeta('use_blurhash'); +export const usePendingItems = getMeta('use_pending_items'); +export const useSystemEmojiFont = getMeta('system_emoji_font'); +export const showTrends = getMeta('trends'); + +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..7e584e8fa --- /dev/null +++ b/app/javascript/flavours/glitch/util/is_mobile.js @@ -0,0 +1,35 @@ +import { supportsPassiveEvents } from 'detect-passive-events'; +import { forceSingleColumn } from 'flavours/glitch/util/initial_state'; + +const LAYOUT_BREAKPOINT = 630; + +export function isMobile(width, columns) { + switch (columns) { + case 'multiple': + return false; + case 'single': + return true; + default: + return forceSingleColumn || width <= LAYOUT_BREAKPOINT; + } +}; + +const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + +let userTouching = false; +let listenerOptions = supportsPassiveEvents ? { 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_keyboard_extensions.js b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js new file mode 100644 index 000000000..2dd0e45fa --- /dev/null +++ b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js @@ -0,0 +1,16 @@ +// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install +// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks +// can at least log in using KaiOS devices). + +function importArrowKeyNavigation() { + return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation'); +} + +export default function loadKeyboardExtensions() { + if (/KAIOS/.test(navigator.userAgent)) { + return importArrowKeyNavigation().then(arrowKeyNav => { + arrowKeyNav.register(); + }); + } + return Promise.resolve(); +} 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..73eedc9dc --- /dev/null +++ b/app/javascript/flavours/glitch/util/load_polyfills.js @@ -0,0 +1,42 @@ +// 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 && + Promise.prototype.finally + ); + + // 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/log_out.js b/app/javascript/flavours/glitch/util/log_out.js new file mode 100644 index 000000000..42dcee03e --- /dev/null +++ b/app/javascript/flavours/glitch/util/log_out.js @@ -0,0 +1,34 @@ +import Rails from '@rails/ujs'; +import { signOutLink } from 'flavours/glitch/util/backend_links'; + +export const logOut = () => { + const form = document.createElement('form'); + + const methodInput = document.createElement('input'); + methodInput.setAttribute('name', '_method'); + methodInput.setAttribute('value', 'delete'); + methodInput.setAttribute('type', 'hidden'); + form.appendChild(methodInput); + + const csrfToken = Rails.csrfToken(); + const csrfParam = Rails.csrfParam(); + + if (csrfParam && csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.setAttribute('name', csrfParam); + csrfInput.setAttribute('value', csrfToken); + csrfInput.setAttribute('type', 'hidden'); + form.appendChild(csrfInput); + } + + const submitButton = document.createElement('input'); + submitButton.setAttribute('type', 'submit'); + form.appendChild(submitButton); + + form.method = 'post'; + form.action = signOutLink; + form.style.display = 'none'; + + document.body.appendChild(form); + submitButton.click(); +}; diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js new file mode 100644 index 000000000..6577b70c2 --- /dev/null +++ b/app/javascript/flavours/glitch/util/main.js @@ -0,0 +1,36 @@ +import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications'; +import { setupBrowserNotifications } from 'flavours/glitch/actions/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); + store.dispatch(setupBrowserNotifications()); + 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/notifications.js b/app/javascript/flavours/glitch/util/notifications.js new file mode 100644 index 000000000..7634cac21 --- /dev/null +++ b/app/javascript/flavours/glitch/util/notifications.js @@ -0,0 +1,30 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + // eslint-disable-next-line promise/catch-or-return + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)).catch(console.warn); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js new file mode 100644 index 000000000..6f2505cae --- /dev/null +++ b/app/javascript/flavours/glitch/util/numbers.js @@ -0,0 +1,71 @@ +// @ts-check + +export const DECIMAL_UNITS = Object.freeze({ + ONE: 1, + TEN: 10, + HUNDRED: Math.pow(10, 2), + THOUSAND: Math.pow(10, 3), + MILLION: Math.pow(10, 6), + BILLION: Math.pow(10, 9), + TRILLION: Math.pow(10, 12), +}); + +const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10; +const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10; + +/** + * @typedef {[number, number, number]} ShortNumber + * Array of: shorten number, unit of shorten number and maximum fraction digits + */ + +/** + * @param {number} sourceNumber Number to convert to short number + * @returns {ShortNumber} Calculated short number + * @example + * shortNumber(5936); + * // => [5.936, 1000, 1] + */ +export function toShortNumber(sourceNumber) { + if (sourceNumber < DECIMAL_UNITS.THOUSAND) { + return [sourceNumber, DECIMAL_UNITS.ONE, 0]; + } else if (sourceNumber < DECIMAL_UNITS.MILLION) { + return [ + sourceNumber / DECIMAL_UNITS.THOUSAND, + DECIMAL_UNITS.THOUSAND, + sourceNumber < TEN_THOUSAND ? 1 : 0, + ]; + } else if (sourceNumber < DECIMAL_UNITS.BILLION) { + return [ + sourceNumber / DECIMAL_UNITS.MILLION, + DECIMAL_UNITS.MILLION, + sourceNumber < TEN_MILLIONS ? 1 : 0, + ]; + } else if (sourceNumber < DECIMAL_UNITS.TRILLION) { + return [ + sourceNumber / DECIMAL_UNITS.BILLION, + DECIMAL_UNITS.BILLION, + 0, + ]; + } + + return [sourceNumber, DECIMAL_UNITS.ONE, 0]; +} + +/** + * @param {number} sourceNumber Original number that is shortened + * @param {number} division The scale in which short number is displayed + * @returns {number} Number that can be used for plurals when short form used + * @example + * pluralReady(1793, DECIMAL_UNITS.THOUSAND) + * // => 1790 + */ +export function pluralReady(sourceNumber, division) { + // eslint-disable-next-line eqeqeq + if (division == null || division < DECIMAL_UNITS.HUNDRED) { + return sourceNumber; + } + + let closestScale = division / DECIMAL_UNITS.TEN; + + return Math.trunc(sourceNumber / closestScale) * closestScale; +} 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..fb8c3c11e --- /dev/null +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -0,0 +1,189 @@ +import EXIF from 'exif-js'; + +const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px + +const _browser_quirks = {}; + +// Some browsers will automatically draw images respecting their EXIF orientation +// while others won't, and the safest way to detect that is to examine how it +// is done on a known image. +// See https://github.com/w3c/csswg-drafts/issues/4666 +// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881 +const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { + switch (_browser_quirks['image-orientation-automatic']) { + case true: + resolve(1); + break; + case false: + resolve(orientation); + break; + default: + // black 2x1 JPEG, with the following meta information set: + // - EXIF Orientation: 6 (Rotated 90° CCW) + const testImageURL = + 'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' + + 'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' + + 'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' + + 'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' + + 'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' + + 'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q=='; + const img = new Image(); + img.onload = () => { + const automatic = (img.width === 1 && img.height === 2); + _browser_quirks['image-orientation-automatic'] = automatic; + resolve(automatic ? 1 : orientation); + }; + img.onerror = () => { + _browser_quirks['image-orientation-automatic'] = false; + resolve(orientation); + }; + img.src = testImageURL; + } +}); + +// Some browsers don't allow reading from a canvas and instead return all-white +// or randomized data. Use a pre-defined image to check if reading the canvas +// works. +const checkCanvasReliability = () => new Promise((resolve, reject) => { + switch(_browser_quirks['canvas-read-unreliable']) { + case true: + reject('Canvas reading unreliable'); + break; + case false: + resolve(); + break; + default: + // 2×2 GIF with white, red, green and blue pixels + const testImageURL = + 'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7'; + const refData = + [255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0, 2, 2); + const imageData = context.getImageData(0, 0, 2, 2); + if (imageData.data.every((x, i) => refData[i] === x)) { + _browser_quirks['canvas-read-unreliable'] = false; + resolve(); + } else { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Canvas reading unreliable'); + } + }; + img.onerror = () => { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Failed to load test image'); + }; + img.src = testImageURL; + } +}); + +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'); + if (orientation !== 1) { + dropOrientationIfNeeded(orientation).then(resolve).catch(() => resolve(orientation)); + } else { + 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))); + + checkCanvasReliability() + .then(getOrientation(img, type)) + .then(orientation => processImage(img, { + width: newWidth, + height: newHeight, + orientation, + type, + })) + .then(resolve) + .catch(reject); +}); + +export default inputFile => new Promise((resolve) => { + 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(() => resolve(inputFile)); +}); 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..84fe58269 --- /dev/null +++ b/app/javascript/flavours/glitch/util/scroll.js @@ -0,0 +1,32 @@ +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; + }; +}; + +const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style; + +export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position); +export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0); diff --git a/app/javascript/flavours/glitch/util/scrollbar.js b/app/javascript/flavours/glitch/util/scrollbar.js new file mode 100644 index 000000000..929b036d6 --- /dev/null +++ b/app/javascript/flavours/glitch/util/scrollbar.js @@ -0,0 +1,34 @@ +/** @type {number | null} */ +let cachedScrollbarWidth = null; + +/** + * @return {number} + */ +const getActualScrollbarWidth = () => { + const outer = document.createElement('div'); + outer.style.visibility = 'hidden'; + outer.style.overflow = 'scroll'; + document.body.appendChild(outer); + + const inner = document.createElement('div'); + outer.appendChild(inner); + + const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; + outer.parentNode.removeChild(outer); + + return scrollbarWidth; +}; + +/** + * @return {number} + */ +export const getScrollbarWidth = () => { + if (cachedScrollbarWidth !== null) { + return cachedScrollbarWidth; + } + + const scrollbarWidth = getActualScrollbarWidth(); + cachedScrollbarWidth = scrollbarWidth; + + return scrollbarWidth; +}; 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..c6d12cd6f --- /dev/null +++ b/app/javascript/flavours/glitch/util/stream.js @@ -0,0 +1,265 @@ +// @ts-check + +import WebSocketClient from '@gamestdio/websocket'; + +/** + * @type {WebSocketClient | undefined} + */ +let sharedConnection; + +/** + * @typedef Subscription + * @property {string} channelName + * @property {Object.<string, string>} params + * @property {function(): void} onConnect + * @property {function(StreamEvent): void} onReceive + * @property {function(): void} onDisconnect + */ + +/** + * @typedef StreamEvent + * @property {string} event + * @property {object} payload + */ + +/** + * @type {Array.<Subscription>} + */ +const subscriptions = []; + +/** + * @type {Object.<string, number>} + */ +const subscriptionCounters = {}; + +/** + * @param {Subscription} subscription + */ +const addSubscription = subscription => { + subscriptions.push(subscription); +}; + +/** + * @param {Subscription} subscription + */ +const removeSubscription = subscription => { + const index = subscriptions.indexOf(subscription); + + if (index !== -1) { + subscriptions.splice(index, 1); + } +}; + +/** + * @param {Subscription} subscription + */ +const subscribe = ({ channelName, params, onConnect }) => { + const key = channelNameWithInlineParams(channelName, params); + + subscriptionCounters[key] = subscriptionCounters[key] || 0; + + if (subscriptionCounters[key] === 0) { + sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params })); + } + + subscriptionCounters[key] += 1; + onConnect(); +}; + +/** + * @param {Subscription} subscription + */ +const unsubscribe = ({ channelName, params, onDisconnect }) => { + const key = channelNameWithInlineParams(channelName, params); + + subscriptionCounters[key] = subscriptionCounters[key] || 1; + + if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) { + sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params })); + } + + subscriptionCounters[key] -= 1; + onDisconnect(); +}; + +const sharedCallbacks = { + connected () { + subscriptions.forEach(subscription => subscribe(subscription)); + }, + + received (data) { + const { stream } = data; + + subscriptions.filter(({ channelName, params }) => { + const streamChannelName = stream[0]; + + if (stream.length === 1) { + return channelName === streamChannelName; + } + + const streamIdentifier = stream[1]; + + if (['hashtag', 'hashtag:local'].includes(channelName)) { + return channelName === streamChannelName && params.tag === streamIdentifier; + } else if (channelName === 'list') { + return channelName === streamChannelName && params.list === streamIdentifier; + } + + return false; + }).forEach(subscription => { + subscription.onReceive(data); + }); + }, + + disconnected () { + subscriptions.forEach(subscription => unsubscribe(subscription)); + }, + + reconnected () { + }, +}; + +/** + * @param {string} channelName + * @param {Object.<string, string>} params + * @return {string} + */ +const channelNameWithInlineParams = (channelName, params) => { + if (Object.keys(params).length === 0) { + return channelName; + } + + return `${channelName}&${Object.keys(params).map(key => `${key}=${params[key]}`).join('&')}`; +}; + +/** + * @param {string} channelName + * @param {Object.<string, string>} params + * @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks + * @return {function(): void} + */ +export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState); + + // If we cannot use a websockets connection, we must fall back + // to using individual connections for each channel + if (!streamingAPIBaseURL.startsWith('ws')) { + const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), { + connected () { + onConnect(); + }, + + received (data) { + onReceive(data); + }, + + disconnected () { + onDisconnect(); + }, + + reconnected () { + onConnect(); + }, + }); + + return () => { + connection.close(); + }; + } + + const subscription = { + channelName, + params, + onConnect, + onReceive, + onDisconnect, + }; + + addSubscription(subscription); + + // If a connection is open, we can execute the subscription right now. Otherwise, + // because we have already registered it, it will be executed on connect + + if (!sharedConnection) { + sharedConnection = /** @type {WebSocketClient} */ (createConnection(streamingAPIBaseURL, accessToken, '', sharedCallbacks)); + } else if (sharedConnection.readyState === WebSocketClient.OPEN) { + subscribe(subscription); + } + + return () => { + removeSubscription(subscription); + unsubscribe(subscription); + }; +}; + +const KNOWN_EVENT_TYPES = [ + 'update', + 'delete', + 'notification', + 'conversation', + 'filters_changed', + 'encrypted_message', + 'announcement', + 'announcement.delete', + 'announcement.reaction', +]; + +/** + * @param {MessageEvent} e + * @param {function(StreamEvent): void} received + */ +const handleEventSourceMessage = (e, received) => { + received({ + event: e.type, + payload: e.data, + }); +}; + +/** + * @param {string} streamingAPIBaseURL + * @param {string} accessToken + * @param {string} channelName + * @param {{ connected: Function, received: function(StreamEvent): void, disconnected: Function, reconnected: Function }} callbacks + * @return {WebSocketClient | EventSource} + */ +const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => { + const params = channelName.split('&'); + + channelName = params.shift(); + + if (streamingAPIBaseURL.startsWith('ws')) { + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + ws.onreconnect = reconnected; + + return ws; + } + + channelName = channelName.replace(/:/g, '/'); + + if (channelName.endsWith(':media')) { + channelName = channelName.replace('/media', ''); + params.push('only_media=true'); + } + + params.push(`access_token=${accessToken}`); + + const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`); + + es.onopen = () => { + connected(); + }; + + KNOWN_EVENT_TYPES.forEach(type => { + es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */ (e), received)); + }); + + es.onerror = /** @type {function(): void} */ (disconnected); + + return es; +}; 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..9c2005c53 --- /dev/null +++ b/app/javascript/flavours/glitch/util/url_regex.js @@ -0,0 +1,30 @@ +import regexSupplant from 'twitter-text/dist/lib/regexSupplant'; +import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars'; +import validDomain from 'twitter-text/dist/regexp/validDomain'; +import validPortNumber from 'twitter-text/dist/regexp/validPortNumber'; +import validUrlPath from 'twitter-text/dist/regexp/validUrlPath'; +import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars'; +import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars'; + +// The difference with twitter-text's extractURL is that the protocol isn't +// optional. + +export const urlRegex = regexSupplant( + '(' + // $1 URL + '(#{validUrlPrecedingChars})' + // $2 + '(https?:\\/\\/)' + // $3 Protocol + '(#{validDomain})' + // $4 Domain(s) + '(?::(#{validPortNumber}))?' + // $5 Port number (optional) + '(\\/#{validUrlPath}*)?' + // $6 URL Path + '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $7 Query String + ')', + { + validUrlPrecedingChars, + validDomain, + validPortNumber, + validUrlPath, + validUrlQueryChars, + validUrlQueryEndingChars, + }, + 'gi', +); 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..8749f2eda --- /dev/null +++ b/app/javascript/flavours/vanilla/names.yml @@ -0,0 +1,24 @@ +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 +es: + flavours: + vanilla: + description: El diseño predeterminado en las instancias de Mastodon. Puede que no soporte todas las características de GlitchSoc. + name: Mastodon Original + skins: + vanilla: + default: Predeterminado diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml new file mode 100644 index 000000000..74e9fb1b5 --- /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: public.js + 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/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 891403969..a60373fd5 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -175,7 +175,9 @@ export function submitCompose(routerHistory) { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertIfOnline('community'); - insertIfOnline('public'); + if (!response.data.local_only) { + insertIfOnline('public'); + } insertIfOnline(`account:${response.data.account.id}`); } }).catch(function (error) { diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index ba2d20cc7..9d8732a8c 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'; @@ -86,7 +87,7 @@ class ComposeForm extends ImmutablePureComponent { const fulltext = this.getFulltextForCharacterCounting(); const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; - return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia)); + return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia)); } handleSubmit = () => { @@ -257,7 +258,7 @@ class ComposeForm extends ImmutablePureComponent { <PrivacyDropdownContainer /> <SpoilerButtonContainer /> </div> - <div className='character-counter__wrapper'><CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} /></div> + <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} /></div> </div> <div className='compose-form__publish'> diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index db49f90eb..88894ae59 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -89,7 +89,7 @@ class Option extends React.PureComponent { <AutosuggestInput placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} - maxLength={50} + maxLength={100} value={title} onChange={this.handleOptionTitleChange} suggestions={this.props.suggestions} @@ -157,7 +157,7 @@ class PollForm extends ImmutablePureComponent { </ul> <div className='poll__footer'> - <button disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> + <button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> {/* eslint-disable-next-line jsx-a11y/no-onchange */} <select value={expiresIn} onChange={this.handleSelectDuration}> diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 80d43907d..89b59051c 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 801d5d2dd..ca25f053e 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -2815,6 +2815,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 0c3ce2f62..a421c3512 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -81,6 +81,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.local_only": "Local only", "community.column_settings.media_only": "Media Only", @@ -284,6 +288,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 ae0e0f5da..18fa04c5e 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -81,6 +81,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.local_only": "ローカルのみ表示", "community.column_settings.media_only": "メディアのみ表示", @@ -290,6 +294,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 c953371e1..6e2ff329e 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -81,6 +81,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.local_only": "Tylko Lokalne", "community.column_settings.media_only": "Tylko zawartość multimedialna", @@ -285,6 +289,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..05dff8e49 --- /dev/null +++ b/app/javascript/packs/common.js @@ -0,0 +1,2 @@ +import './public-path'; +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 8c5c15b8f..2166d8df0 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,5 +1,4 @@ import './public-path'; -import escapeTextContentForBrowser from 'escape-html'; import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; @@ -7,22 +6,6 @@ import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; 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'); @@ -163,114 +146,6 @@ function main() { }); }); - delegate(document, '.webapp-btn', 'click', ({ target, button }) => { - if (button !== 0) { - return true; - } - window.location.href = target.href; - 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 = target.dataset.default; - } - } - }); - - 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') !== 'true' && 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 (lock) { - if (target.checked) { - delete lock.dataset.hidden; - } else { - lock.dataset.hidden = 'true'; - } - } - }); - - 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; - }); - delegate(document, '.sidebar__toggle__icon', 'click', () => { const target = document.querySelector('.sidebar ul'); 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..570e30d3b --- /dev/null +++ b/app/javascript/skins/glitch/contrast/names.yml @@ -0,0 +1,8 @@ +en: + skins: + glitch: + contrast: High contrast +es: + skins: + glitch: + contrast: Alto contraste 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..891271e6e --- /dev/null +++ b/app/javascript/skins/glitch/mastodon-light/names.yml @@ -0,0 +1,8 @@ +en: + skins: + glitch: + mastodon-light: Mastodon (light) +es: + skins: + glitch: + mastodon-light: Mastodon (claro) 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..da8974742 --- /dev/null +++ b/app/javascript/skins/vanilla/contrast/names.yml @@ -0,0 +1,8 @@ +en: + skins: + vanilla: + contrast: High contrast +es: + skins: + vanilla: + contrast: Alto contraste 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..5e41d1051 --- /dev/null +++ b/app/javascript/skins/vanilla/mastodon-light/names.yml @@ -0,0 +1,8 @@ +en: + skins: + vanilla: + mastodon-light: Mastodon (light) +es: + skins: + glitch: + mastodon-light: Mastodon (claro) 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 25ebc19b0..4801a4644 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -443,6 +443,22 @@ body, } } +.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 7ca027e81..2ca844076 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1768,7 +1768,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; } @@ -6567,7 +6567,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/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/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 9a2960507..cc2d391fb 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -132,7 +132,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity # If there is at least one silent mention, then the status can be considered # as a limited-audience status, and not strictly a direct message, but only # if we considered a direct message in the first place - next unless @params[:visibility] == :direct + next unless @params[:visibility] == :direct && direct_message.nil? @params[:visibility] = :limited end @@ -143,7 +143,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true) - return unless @params[:visibility] == :direct + return unless @params[:visibility] == :direct && direct_message.nil? @params[:visibility] = :limited end @@ -154,7 +154,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity delivered_to_account = Account.find(@options[:delivered_to_account_id]) @status.mentions.create(account: delivered_to_account, silent: true) - @status.update(visibility: :limited) if @status.direct_visibility? + @status.update(visibility: :limited) if @status.direct_visibility? && direct_message.nil? return unless delivered_to_account.following?(@account) @@ -353,6 +353,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity :unlisted elsif audience_to.include?(@account.followers_url) :private + elsif direct_message == false + :limited else :direct end @@ -363,6 +365,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity audience_to.include?(uri) || audience_cc.include?(uri) end + def direct_message + @object['directMessage'] + end + def replied_to_status return @replied_to_status if defined?(@replied_to_status) diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 2d6b87659..ef00a4e2e 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -7,6 +7,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base }.freeze CONTEXT_EXTENSION_MAP = { + direct_message: { 'litepub': 'http://litepub.social/ns#', 'directMessage': 'litepub:directMessage' }, manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' }, sensitive: { 'sensitive' => 'as:sensitive' }, hashtag: { 'Hashtag' => 'as:Hashtag' }, diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index d5e435216..d57508ef9 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -45,6 +45,8 @@ class FeedManager filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status])) when :mentions filter_from_mentions?(status, receiver.id) + when :direct + filter_from_direct?(status, receiver.id) else false end @@ -96,6 +98,29 @@ class FeedManager true end + # Add a status to a linear direct message feed and send a streaming API update + # @param [Account] account + # @param [Status] status + # @return [Boolean] + def push_to_direct(account, status) + return false unless add_to_feed(:direct, account.id, status) + + trim(:direct, account.id) + PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}") + true + end + + # Remove a status from a linear direct message feed and send a streaming API update + # @param [List] list + # @param [Status] status + # @return [Boolean] + def unpush_from_direct(account, status) + return false unless remove_from_feed(:direct, account.id, status) + + redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + true + end + # Fill a home feed with an account's statuses # @param [Account] from_account # @param [Account] into_account @@ -260,6 +285,30 @@ class FeedManager end end + # Populate direct feed of account from scratch + # @param [Account] account + # @return [void] + def populate_direct_feed(account) + added = 0 + limit = FeedManager::MAX_ITEMS / 2 + max_id = nil + + loop do + statuses = Status.as_direct_timeline(account, limit, max_id) + + break if statuses.empty? + + statuses.each do |status| + next if filter_from_direct?(status, account) + added += 1 if add_to_feed(:direct, account.id, status) + end + + break unless added.zero? + + max_id = statuses.last.id + end + end + # Completely clear multiple feeds at once # @param [Symbol] type # @param [Array<Integer>] ids @@ -398,6 +447,15 @@ class FeedManager should_filter end + # Check if status should not be added to the linear direct message feed + # @param [Status] status + # @param [Integer] receiver_id + # @return [Boolean] + def filter_from_direct?(status, receiver_id) + return false if receiver_id == status.account_id + filter_from_mentions?(status, receiver_id) + end + # Check if status should not be added to the list feed # @param [Status] status # @param [List] list diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index fd6537526..b26138642 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -2,6 +2,29 @@ require 'singleton' +class HTMLRenderer < Redcarpet::Render::HTML + def block_code(code, language) + "<pre><code>#{encode(code).gsub("\n", "<br/>")}</code></pre>" + end + + def autolink(link, link_type) + return link if link_type == :email + Formatter.instance.link_url(link) + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError + encode(link) + end + + private + + def html_entities + @html_entities ||= HTMLEntities.new + end + + def encode(html) + html_entities.encode(html) + end +end + class Formatter include Singleton include RoutingHelper @@ -35,16 +58,26 @@ class Formatter html = raw_content html = "RT @#{prepend_reblog} #{html}" if prepend_reblog - html = encode_and_link_urls(html, linkable_accounts) + html = format_markdown(html) if status.content_type == 'text/markdown' + html = encode_and_link_urls(html, linkable_accounts, keep_html: %w(text/markdown text/html).include?(status.content_type)) + html = reformat(html, true) if %w(text/markdown text/html).include?(status.content_type) html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] - html = simple_format(html, {}, sanitize: false) - html = html.delete("\n") + + unless %w(text/markdown text/html).include?(status.content_type) + html = simple_format(html, {}, sanitize: false) + html = html.delete("\n") + end html.html_safe # rubocop:disable Rails/OutputSafety end - def reformat(html) - sanitize(html, Sanitize::Config::MASTODON_STRICT) + def format_markdown(html) + html = markdown_formatter.render(html) + html.delete("\r").delete("\n") + end + + def reformat(html, outgoing = false) + sanitize(html, Sanitize::Config::MASTODON_STRICT.merge(outgoing: outgoing)) rescue ArgumentError '' end @@ -98,8 +131,40 @@ class Formatter html.html_safe # rubocop:disable Rails/OutputSafety end + def link_url(url) + "<a href=\"#{encode(url)}\" target=\"blank\" rel=\"nofollow noopener noreferrer\">#{link_html(url)}</a>" + end + private + def markdown_formatter + extensions = { + autolink: true, + no_intra_emphasis: true, + fenced_code_blocks: true, + disable_indented_code_blocks: true, + strikethrough: true, + lax_spacing: true, + space_after_headers: true, + superscript: true, + underline: true, + highlight: true, + footnotes: false, + } + + renderer = HTMLRenderer.new({ + filter_html: false, + escape_html: false, + no_images: true, + no_styles: true, + safe_links_only: true, + hard_wrap: true, + link_attributes: { target: '_blank', rel: 'nofollow noopener' }, + }) + + Redcarpet::Markdown.new(renderer, extensions) + end + def html_entities @html_entities ||= HTMLEntities.new end @@ -109,14 +174,14 @@ class Formatter end def encode_and_link_urls(html, accounts = nil, options = {}) - entities = utf8_friendly_extractor(html, extract_url_without_protocol: false) - if accounts.is_a?(Hash) options = accounts accounts = nil end - rewrite(html.dup, entities) do |entity| + entities = options[:keep_html] ? html_friendly_extractor(html) : utf8_friendly_extractor(html, extract_url_without_protocol: false) + + rewrite(html.dup, entities, options[:keep_html]) do |entity| if entity[:url] link_to_url(entity, options) elsif entity[:hashtag] @@ -191,7 +256,7 @@ class Formatter end # rubocop:enable Metrics/BlockNesting - def rewrite(text, entities) + def rewrite(text, entities, keep_html = false) text = text.to_s # Sort by start index @@ -204,12 +269,12 @@ class Formatter last_index = entities.reduce(0) do |index, entity| indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices] - result << encode(text[index...indices.first]) + result << (keep_html ? text[index...indices.first] : encode(text[index...indices.first])) result << yield(entity) indices.last end - result << encode(text[last_index..-1]) + result << (keep_html ? text[last_index..-1] : encode(text[last_index..-1])) result.flatten.join end @@ -253,6 +318,29 @@ class Formatter Extractor.remove_overlapping_entities(special + standard + extra) end + def html_friendly_extractor(html, options = {}) + gaps = [] + total_offset = 0 + + escaped = html.gsub(/<[^>]*>|&#[0-9]+;/) do |match| + total_offset += match.length - 1 + end_offset = Regexp.last_match.end(0) + gaps << [end_offset - total_offset, total_offset] + "\u200b" + end + + entities = Extractor.extract_hashtags_with_indices(escaped, :check_url_overlap => false) + + Extractor.extract_mentions_or_lists_with_indices(escaped) + Extractor.remove_overlapping_entities(entities).map do |extract| + pos = extract[:indices].first + offset_idx = gaps.rindex { |gap| gap.first <= pos } + offset = offset_idx.nil? ? 0 : gaps[offset_idx].last + next extract.merge( + :indices => [extract[:indices].first + offset, extract[:indices].last + offset] + ) + end + end + def link_to_url(entity, options = {}) url = Addressable::URI.parse(entity[:url]) html_attrs = { target: '_blank', rel: 'nofollow noopener noreferrer' } diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb index 1e18d6d46..796de1113 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 noindex ).freeze diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index 39a98c3eb..a1d12a654 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -24,8 +24,11 @@ class TagManager def local_url?(url) uri = Addressable::URI.parse(url).normalize + return false unless uri.host domain = uri.host + (uri.port ? ":#{uri.port}" : '') TagManager.instance.web_domain?(domain) + rescue Addressable::URI::InvalidURIError + false end end 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 e37bc6d9f..581101782 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,12 +30,16 @@ class UserSettingsDecorator user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion') user.settings['disable_swiping'] = disable_swiping_preference if change?('setting_disable_swiping') user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') + user.settings['system_emoji_font'] = system_emoji_font_preference if change?('setting_system_emoji_font') 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') user.settings['advanced_layout'] = advanced_layout_preference if change?('setting_advanced_layout') + user.settings['default_content_type']= default_content_type_preference if change?('setting_default_content_type') user.settings['use_blurhash'] = use_blurhash_preference if change?('setting_use_blurhash') user.settings['use_pending_items'] = use_pending_items_preference if change?('setting_use_pending_items') user.settings['trends'] = trends_preference if change?('setting_trends') @@ -65,6 +70,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 @@ -73,6 +82,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_system_font_ui' end + def system_emoji_font_preference + boolean_cast_setting 'setting_system_emoji_font' + end + def auto_play_gif_preference boolean_cast_setting 'setting_auto_play_gif' end @@ -97,6 +110,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 @@ -105,10 +130,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 @@ -121,6 +142,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_advanced_layout' end + def default_content_type_preference + settings['setting_default_content_type'] + end + def use_blurhash_preference boolean_cast_setting 'setting_use_blurhash' end diff --git a/app/models/account.rb b/app/models/account.rb index 2c5455d8e..53c6a43a6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -73,6 +73,10 @@ class Account < ApplicationRecord include DomainMaterializable include AccountMerging + 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 + TRUST_LEVELS = { untrusted: 0, trusted: 1, @@ -90,9 +94,9 @@ class Account < ApplicationRecord # Local user validations validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' } 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: 500 }, 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) } @@ -320,15 +324,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/custom_emoji.rb b/app/models/custom_emoji.rb index 7cb03b819..f14357932 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -21,7 +21,8 @@ # class CustomEmoji < ApplicationRecord - LIMIT = 50.kilobytes + LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 50.kilobytes).to_i + LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 200.kilobytes).to_i].max SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' @@ -38,7 +39,9 @@ class CustomEmoji < ApplicationRecord before_validation :downcase_domain - validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT } + validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true + validates_attachment_size :image, less_than: LIMIT, unless: :local? + validates_attachment_size :image, less_than: LOCAL_LIMIT, if: :local? validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } scope :local, -> { where(domain: nil) } diff --git a/app/models/direct_feed.rb b/app/models/direct_feed.rb new file mode 100644 index 000000000..1f2448070 --- /dev/null +++ b/app/models/direct_feed.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DirectFeed < Feed + include Redisable + + def initialize(account) + @type = :direct + @id = account.id + @account = account + end + + def get(limit, max_id = nil, since_id = nil, min_id = nil) + unless redis.exists("account:#{@account.id}:regeneration") + statuses = super + return statuses unless statuses.empty? + end + from_database(limit, max_id, since_id, min_id) + end + + private + + def from_database(limit, max_id, since_id, min_id) + loop do + statuses = Status.as_direct_timeline(@account, limit, max_id, since_id, min_id) + return statuses if statuses.empty? + max_id = statuses.last.id + statuses = statuses.reject { |status| FeedManager.instance.filter?(:direct, status, @account) } + return statuses unless statuses.empty? + end + end +end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 6fc7c56fd..0276ec058 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,14 +26,20 @@ 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 trends trendable_by_default show_domain_blocks show_domain_blocks_rationale noindex + outgoing_spoilers require_invite_text ).freeze @@ -45,6 +52,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 trends trendable_by_default noindex @@ -57,6 +68,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 } @@ -78,6 +93,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? @@ -90,10 +106,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/media_attachment.rb b/app/models/media_attachment.rb index 3515f6895..a6ab22f61 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -150,8 +150,8 @@ class MediaAttachment < ApplicationRecord all: '-quality 90 -strip +set modify-date +set create-date', }.freeze - IMAGE_LIMIT = 10.megabytes - VIDEO_LIMIT = 40.megabytes + IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i + VIDEO_LIMIT = (ENV['MAX_VIDEO_SIZE'] || 40.megabytes).to_i MAX_VIDEO_MATRIX_LIMIT = 2_304_000 # 1920x1200px MAX_VIDEO_FRAME_RATE = 60 diff --git a/app/models/mute.rb b/app/models/mute.rb index 578345ef6..fe8b6f42c 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -6,6 +6,7 @@ # 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 diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 5e4c3e1ce..2528ef1b6 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -8,6 +8,7 @@ class PublicFeed # @option [Boolean] :local # @option [Boolean] :remote # @option [Boolean] :only_media + # @option [Boolean] :allow_local_only def initialize(account, options = {}) @account = account @options = options @@ -21,6 +22,7 @@ class PublicFeed def get(limit, max_id = nil, since_id = nil, min_id = nil) scope = public_scope + scope.merge!(without_local_only_scope) unless allow_local_only? scope.merge!(without_replies_scope) unless with_replies? scope.merge!(without_reblogs_scope) unless with_reblogs? scope.merge!(local_only_scope) if local_only? @@ -35,6 +37,10 @@ class PublicFeed attr_reader :account, :options + def allow_local_only? + local_account? && (local_only? || options[:allow_local_only]) + end + def with_reblogs? options[:with_reblogs] end @@ -55,6 +61,10 @@ class PublicFeed account.present? end + def local_account? + account&.local? + end + def media_only? options[:only_media] end @@ -83,6 +93,10 @@ class PublicFeed Status.joins(:media_attachments).group(:id) end + def without_local_only_scope + Status.not_local_only + end + def account_filters_scope Status.not_excluded_by_account(account).tap do |scope| scope.merge!(Status.not_domain_blocked_by_account(account)) unless local_only? diff --git a/app/models/status.rb b/app/models/status.rb index 847921ac2..9f673ee53 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -21,7 +21,10 @@ # 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) +# content_type :string # deleted_at :datetime # @@ -77,6 +80,7 @@ class Status < ApplicationRecord validates_with DisallowedHashtagsValidator validates :reblog, uniqueness: { scope: :account }, if: :reblog? validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog? + validates :content_type, inclusion: { in: %w(text/plain text/markdown text/html) }, allow_nil: true accepts_nested_attributes_for :poll @@ -107,6 +111,8 @@ class Status < ApplicationRecord end } + scope :not_local_only, -> { where(local_only: [false, nil]) } + cache_associated :application, :media_attachments, :conversation, @@ -260,6 +266,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 @@ -273,6 +281,51 @@ class Status < ApplicationRecord visibilities.keys - %w(direct limited) end + def in_chosen_languages(account) + where(language: nil).or where(language: account.chosen_languages) + end + + def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false) + # direct timeline is mix of direct message from_me and to_me. + # 2 queries are executed with pagination. + # constant expression using arel_table is required for partial index + + # _from_me part does not require any timeline filters + query_from_me = where(account_id: account.id) + .where(Status.arel_table[:visibility].eq(3)) + .limit(limit) + .order('statuses.id DESC') + + # _to_me part requires mute and block filter. + # FIXME: may we check mutes.hide_notifications? + query_to_me = Status + .joins(:mentions) + .merge(Mention.where(account_id: account.id)) + .where(Status.arel_table[:visibility].eq(3)) + .limit(limit) + .order('mentions.status_id DESC') + .not_excluded_by_account(account) + + if max_id.present? + query_from_me = query_from_me.where('statuses.id < ?', max_id) + query_to_me = query_to_me.where('mentions.status_id < ?', max_id) + end + + if since_id.present? + query_from_me = query_from_me.where('statuses.id > ?', since_id) + query_to_me = query_to_me.where('mentions.status_id > ?', since_id) + end + + if cache_ids + # returns array of cache_ids object that have id and updated_at + (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit) + else + # returns ActiveRecord.Relation + items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit) + Status.where(id: items.map(&:id)) + end + end + def favourites_map(status_ids, account_id) 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 @@ -317,7 +370,7 @@ class Status < ApplicationRecord visibility = [:public, :unlisted] if account.nil? - where(visibility: visibility) + where(visibility: visibility).not_local_only elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps none elsif account.id == target_account.id # author can see own stuff @@ -351,6 +404,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 + def status_stat super || build_status_stat end @@ -386,6 +448,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/tag_feed.rb b/app/models/tag_feed.rb index b8cd63557..fbbdbaae2 100644 --- a/app/models/tag_feed.rb +++ b/app/models/tag_feed.rb @@ -25,6 +25,7 @@ class TagFeed < PublicFeed def get(limit, max_id = nil, since_id = nil, min_id = nil) scope = public_scope + scope.merge!(without_local_only_scope) unless local_account? scope.merge!(tagged_with_any_scope) scope.merge!(tagged_with_all_scope) scope.merge!(tagged_with_none_scope) diff --git a/app/models/user.rb b/app/models/user.rb index 4973c68b6..5c5e926e6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -120,11 +120,11 @@ 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, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, - :disable_swiping, + :disable_swiping, :default_content_type, :system_emoji_font, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code, :sign_in_token_attempt @@ -204,7 +204,7 @@ class User < ApplicationRecord end def functional? - confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial? && account.moved_to_account_id.nil? + confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial? end def unconfirmed_or_pending? diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index bcf9c3395..d0359580d 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -13,6 +13,7 @@ class StatusPolicy < ApplicationPolicy def show? return false if author.suspended? + return false if local_only? && (current_account.nil? || !current_account.local?) if requires_mention? owned? || mention_exists? @@ -92,4 +93,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 8cd774abe..345a5e5e9 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -44,6 +44,15 @@ class InstancePresenter Mastodon::Version end + def commit_hash + current_release_file = Pathname.new('CURRENT_RELEASE').expand_path + if current_release_file.file? + IO.read(current_release_file).strip! + else + '' + end + end + def source_url Mastodon::Version.source_url end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 7c52b634d..e08c537b0 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ActivityPub::NoteSerializer < ActivityPub::Serializer - context_extensions :atom_uri, :conversation, :sensitive, :voters_count + context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message attributes :id, :type, :summary, :in_reply_to, :published, :url, @@ -12,6 +12,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer attribute :content attribute :content_map, if: :language? + attribute :direct_message, if: :non_public? + has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag @@ -26,6 +28,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer attribute :voters_count, if: :poll_and_voters_count? def id + raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only] ActivityPub::TagManager.instance.uri_for(object) end @@ -34,7 +37,15 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def summary - object.spoiler_text.presence + object.spoiler_text.presence || (instance_options[:allow_local_only] ? nil : Setting.outgoing_spoilers.presence) + end + + def direct_message + object.direct_visibility? + end + + def non_public? + !object.distributable? end def content @@ -96,7 +107,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def sensitive - object.account.sensitized? || object.sensitive + object.account.sensitized? || object.sensitive || (!instance_options[:allow_local_only] && Setting.outgoing_spoilers.present?) end def virtual_tags diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 70263e0c5..470cec8a1 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, @@ -28,6 +42,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 @@ -39,6 +54,8 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_pending_items] = object.current_account.user.setting_use_pending_items store[:is_staff] = object.current_account.user.staff? store[:trends] = Setting.trends && object.current_account.user.setting_trends + store[:default_content_type] = object.current_account.user.setting_default_content_type + store[:system_emoji_font] = object.current_account.user.setting_system_emoji_font store[:crop_images] = object.current_account.user.setting_crop_images else store[:auto_play_gif] = Setting.auto_play_gif diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index a78ec4507..36886181f 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -63,6 +63,10 @@ class REST::AccountSerializer < ActiveModel::Serializer object.last_status_at&.to_date&.iso8601 end + def followers_count + (Setting.hide_followers_count || object.user&.setting_hide_followers_count) ? -1 : object.followers_count + end + def display_name object.suspended? ? '' : object.display_name end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index d39092b56..ae8b80fb7 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, :short_description, :description, :email, - :version, :urls, :stats, :thumbnail, + :version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits, :languages, :registrations, :approval_required, :invites_enabled has_one :contact_account, serializer: REST::AccountSerializer @@ -41,6 +41,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/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 bb6df90b7..b5dcf6208 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -11,9 +11,11 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :muted, if: :current_user? attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? + attribute :local_only if :local? attribute :content, unless: :source_requested? attribute :text, if: :source_requested? + attribute :content_type, if: :source_requested? belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application, if: :show_application? diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 6a1575616..749c84736 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -22,7 +22,7 @@ class BackupService < BaseService account.statuses.with_includes.reorder(nil).find_in_batches do |statuses| statuses.each do |status| - item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account) + item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true) item.delete(:'@context') unless item[:type] == 'Announce' || item[:object][:attachment].blank? @@ -153,7 +153,8 @@ class BackupService < BaseService ActiveModelSerializers::SerializableResource.new( object, serializer: serializer, - adapter: ActivityPub::Adapter + adapter: ActivityPub::Adapter, + allow_local_only: true, ).as_json end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index b54bcae35..363aa5ccf 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -21,6 +21,7 @@ class BatchedRemoveStatusService < BaseService statuses_with_account_conversations.each do |status| status.send(:unlink_from_conversations) + unpush_from_direct_timelines(status) end # We do not batch all deletes into one to avoid having a long-running @@ -90,4 +91,10 @@ class BatchedRemoveStatusService < BaseService redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local? end end + + def unpush_from_direct_timelines(status) + status.mentions.each do |mention| + FeedManager.instance.unpush_from_direct(mention.account, status) if mention.account.local? + end + end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index b72bb82d3..6fa98ce12 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -10,6 +10,7 @@ class FanOutOnWriteService < BaseService 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) @@ -18,13 +19,14 @@ class FanOutOnWriteService < BaseService 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 render_anonymous_payload(status) 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? @@ -35,6 +37,7 @@ class FanOutOnWriteService < BaseService def deliver_to_self(status) Rails.logger.debug "Delivering status #{status.id} to author" FeedManager.instance.push_to_home(status.account, status) + FeedManager.instance.push_to_direct(status.account, status) if status.direct_visibility? end def deliver_to_followers(status) @@ -103,6 +106,14 @@ class FanOutOnWriteService < BaseService end end + def deliver_to_direct_timelines(status) + Rails.logger.debug "Delivering status #{status.id} to direct timelines" + + FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account| + [status.id, account.id, :direct] + end + 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 0a383d6a3..250d0e8ed 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -48,8 +48,17 @@ class PostStatusService < BaseService private def preprocess_attributes! + if @text.blank? && @options[:spoiler_text].present? + @text = '.' + if @media&.find(&:video?) || @media&.find(&:gifv?) + @text = '📹' + elsif @media&.find(&:audio?) + @text = '🎵' + elsif @media&.find(&:image?) + @text = '🖼' + end + end @sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present? - @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? @visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced? @scheduled_at = @options[:scheduled_at]&.to_datetime @@ -90,7 +99,7 @@ class PostStatusService < BaseService def postprocess_status! LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text? DistributionWorker.perform_async(@status.id) - ActivityPub::DistributionWorker.perform_async(@status.id) + ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only? PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll end @@ -163,6 +172,7 @@ class PostStatusService < BaseService visibility: @visibility, language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account), application: @options[:application], + content_type: @options[:content_type] || @account.user&.setting_default_content_type, rate_limit: @options[:with_rate_limit], }.compact end diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 61f573534..b4fa70710 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -3,6 +3,7 @@ class PrecomputeFeedService < BaseService def call(account) FeedManager.instance.populate_home(account) + FeedManager.instance.populate_direct_feed(account) ensure Redis.current.del("account:#{account.id}:regeneration") end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 73dbb1834..ec4cb11f9 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -58,7 +58,7 @@ class ProcessMentionsService < BaseService if mentioned_account.local? LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) - 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, { synchronize_followers: !mention.status.distributable? }) end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 744bdf567..f41276de0 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -31,7 +31,7 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit]) DistributionWorker.perform_async(reblog.id) - ActivityPub::DistributionWorker.perform_async(reblog.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only? 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 b680c8e96..20c17e6df 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -40,6 +40,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? remove_media end @@ -54,6 +55,7 @@ class RemoveStatusService < BaseService def remove_from_self FeedManager.instance.unpush_from_home(@account, @status) + FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility? end def remove_from_followers @@ -134,6 +136,12 @@ class RemoveStatusService < BaseService redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload) end + def remove_from_direct + @status.active_mentions.each do |mention| + FeedManager.instance.unpush_from_direct(mention.account, @status) if mention.account.local? + end + end + def remove_media return if @options[:redraft] || (!@options[:immediate] && @status.reported?) diff --git a/app/validators/poll_validator.rb b/app/validators/poll_validator.rb index a32727796..1aaf5a5d0 100644 --- a/app/validators/poll_validator.rb +++ b/app/validators/poll_validator.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class PollValidator < ActiveModel::Validator - MAX_OPTIONS = 4 - MAX_OPTION_CHARS = 50 + MAX_OPTIONS = (ENV['MAX_POLL_OPTIONS'] || 5).to_i + MAX_OPTION_CHARS = (ENV['MAX_POLL_OPTION_CHARS'] || 100).to_i MAX_EXPIRATION = 1.month.freeze MIN_EXPIRATION = 5.minutes.freeze diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb index d036f1925..11997024f 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 URL_PLACEHOLDER = "\1#{'x' * 23}" def validate(status) 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 cd93b0f58..1cf194522 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', crossorigin: 'anonymous' = render partial: 'shared/og' .grid-4 diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index cae5a5ac9..76dec18b1 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/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml index f7f73150b..347eca166 100644 --- a/app/views/admin/action_logs/index.html.haml +++ b/app/views/admin/action_logs/index.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('admin.action_logs.title') -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - = form_tag admin_action_logs_url, method: 'GET', class: 'simple_form' do = hidden_field_tag :target_account_id, params[:target_account_id] if params[:target_account_id].present? diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml index bfec0407e..b6cf7ba64 100644 --- a/app/views/admin/custom_emojis/index.html.haml +++ b/app/views/admin/custom_emojis/index.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('admin.custom_emojis.title') -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - - if can?(:create, :custom_emoji) - content_for :heading_actions do = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index e8a2b46fd..e25b80846 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -74,6 +74,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.trends'), edit_admin_settings_path), @trends_enabled) %li = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) diff --git a/app/views/admin/domain_allows/new.html.haml b/app/views/admin/domain_allows/new.html.haml index 249a961ce..85ab7e464 100644 --- a/app/views/admin/domain_allows/new.html.haml +++ b/app/views/admin/domain_allows/new.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('admin.domain_allows.add_new') diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml index 6fe2edc82..c76a7a7f8 100644 --- a/app/views/admin/domain_blocks/edit.html.haml +++ b/app/views/admin/domain_blocks/edit.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('admin.domain_blocks.edit') diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 8b78f71f2..b04ce7d9c 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', async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('.title') diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml index 5b949a165..2878f07d7 100644 --- a/app/views/admin/follow_recommendations/show.html.haml +++ b/app/views/admin/follow_recommendations/show.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('admin.follow_recommendations.title') -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - %p= t('admin.follow_recommendations.description_html') %hr.spacer/ diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml index d5b983de9..00593840c 100644 --- a/app/views/admin/ip_blocks/index.html.haml +++ b/app/views/admin/ip_blocks/index.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('admin.ip_blocks.title') -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - - if can?(:create, :ip_block) - content_for :heading_actions do = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button' diff --git a/app/views/admin/pending_accounts/index.html.haml b/app/views/admin/pending_accounts/index.html.haml index 8384a1c9f..8101d7f99 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', 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 b060c553f..167e96c03 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', 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 33bfc43d3..373811ea3 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -1,6 +1,3 @@ -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('admin.settings.title') @@ -15,7 +12,7 @@ .fields-row .fields-row__column.fields-row__column-6.fields-group - = f.input :theme, collection: Themes.instance.names, label: t('simple_form.labels.defaults.setting_theme'), 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}") } @@ -89,6 +86,18 @@ .fields-group = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.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 @@ -101,6 +110,9 @@ = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks_rationale.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' .fields-group + = f.input :outgoing_spoilers, wrapper: :with_label, label: t('admin.settings.outgoing_spoilers.title'), hint: t('admin.settings.outgoing_spoilers.desc_html') + + .fields-group = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } unless whitelist_mode? = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml index c39ba9071..5414d69d5 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', async: true, crossorigin: 'anonymous' - - content_for :page_title do = t('admin.statuses.title') \- diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml index d78f3c6d1..e25b0ae84 100644 --- a/app/views/admin/tags/index.html.haml +++ b/app/views/admin/tags/index.html.haml @@ -1,9 +1,6 @@ - content_for :page_title do = t('admin.tags.title') -- content_for :header_tags do - = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - .filters .filter-subset %strong= t('admin.tags.review') diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml index b897a0422..1867ec7f8 100644 --- a/app/views/auth/sessions/two_factor.html.haml +++ b/app/views/auth/sessions/two_factor.html.haml @@ -1,8 +1,6 @@ - content_for :page_title do = t('auth.login') -=javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous' - - if @webauthn_enabled = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' } diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml index 7975ee999..febfb7d17 100644 --- a/app/views/directories/index.html.haml +++ b/app/views/directories/index.html.haml @@ -42,7 +42,7 @@ = number_to_human account.statuses_count, strip_insignificant_zeros: true %small= t('accounts.posts', count: account.statuses_count).downcase .accounts-table__count - = 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 .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 3d6283fba..568b23eff 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -6,7 +6,6 @@ %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} = render_initial_state - = javascript_pack_tag 'application', crossorigin: 'anonymous' .notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } } %noscript diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml new file mode 100644 index 000000000..92de64b0d --- /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]}", crossorigin: 'anonymous' + - if theme[:skin] + - if !theme[:flavour] || theme[:skin] == 'default' + = stylesheet_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", media: 'all', crossorigin: 'anonymous' + - else + = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}", crossorigin: 'anonymous' + - 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 62716ab1e..ec3629dd8 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,6 +1,5 @@ - content_for :header_tags do = render_initial_state - = javascript_pack_tag 'public', crossorigin: 'anonymous' - content_for :content do .admin-wrapper diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index f5a963e00..09826afb3 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -21,20 +21,26 @@ %title= content_for?(:page_title) ? safe_join([yield(:page_title).chomp.html_safe, title], ' - ') : title - = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous' - = stylesheet_pack_tag current_theme, media: 'all', crossorigin: 'anonymous' - = javascript_pack_tag 'common', crossorigin: 'anonymous' - = javascript_pack_tag "locale_#{I18n.locale}", crossorigin: 'anonymous' + = javascript_pack_tag "locales", crossorigin: 'anonymous' + - if @theme + - if @theme[:supported_locales].include? I18n.locale.to_s + = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous' + - elsif @theme[:supported_locales].include? 'en' + = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous' = csrf_meta_tags %meta{ name: 'style-nonce', content: request.content_security_policy_nonce } = stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style' + = 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, host: request.host, 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 0ea3bbe3b..ba105d25e 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', 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 719c21a9a..2a2996d28 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -11,12 +11,16 @@ - if storage_host? %link{ rel: 'dns-prefetch', href: storage_host }/ - = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all', crossorigin: 'anonymous' - = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' - = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = render_initial_state - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' + = javascript_pack_tag "locales", crossorigin: 'anonymous' + - if @theme + - if @theme[:supported_locales].include? I18n.locale.to_s + = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous' + - elsif @theme[:supported_locales].include? 'en' + = javascript_pack_tag "locales/#{@theme[:flavour]}/en", 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 852a0c69b..55da5de3f 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', crossorigin: 'anonymous' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all', crossorigin: 'anonymous' - = javascript_pack_tag 'common', crossorigin: 'anonymous' - = javascript_pack_tag 'error', crossorigin: 'anonymous' + = javascript_pack_tag "locales", 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 343bcb265..8b69d758b 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 a2cd1193f..08b4c852f 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', 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 e63cf0848..eaa0437c2 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -1,6 +1,5 @@ - content_for :header_tags do = render_initial_state - = javascript_pack_tag 'public', crossorigin: 'anonymous' - content_for :content do .public-layout diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml index f00c8f040..c1d630a63 100644 --- a/app/views/media/player.html.haml +++ b/app/views/media/player.html.haml @@ -1,6 +1,13 @@ - content_for :header_tags do = render_initial_state - = javascript_pack_tag 'public', crossorigin: 'anonymous' + = javascript_pack_tag "locales", crossorigin: 'anonymous' + - if @theme + - if @theme[:supported_locales].include? I18n.locale.to_s + = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous' + - elsif @theme[:supported_locales].include? 'en' + = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous' + = render partial: 'layouts/theme', object: @core + = render partial: 'layouts/theme', object: @theme :ruby meta = @media_attachment.file.meta || {} diff --git a/app/views/public_timelines/show.html.haml b/app/views/public_timelines/show.html.haml index 9254bd348..71a3d289b 100644 --- a/app/views/public_timelines/show.html.haml +++ b/app/views/public_timelines/show.html.haml @@ -3,7 +3,6 @@ - content_for :header_tags do %meta{ name: 'robots', content: 'noindex' }/ - = javascript_pack_tag 'about', 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 c82e639e0..7ad4e08f6 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', 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/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 14941d5fd..ccea2e9b7 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -5,11 +5,8 @@ = button_tag t('generic.save_changes'), class: 'button', form: 'edit_user' = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f| - .fields-row - .fields-group.fields-row__column.fields-row__column-6 - = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale, hint: false - .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 + = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale, hint: false - unless I18n.locale == :en .flash-message.translation-prompt @@ -32,6 +29,7 @@ = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label = f.input :setting_disable_swiping, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label + = f.input :setting_system_emoji_font, as: :boolean, wrapper: :with_label %h4= t 'appearance.toot_layout' @@ -48,6 +46,7 @@ .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 %h4= t 'appearance.sensitive_content' diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index 539a70056..3b5c7016d 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -16,6 +16,10 @@ .fields-group = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true + - unless Setting.hide_followers_count + .fields-group + = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label + %h4= t 'preferences.posting_defaults' .fields-row @@ -31,6 +35,9 @@ .fields-group = f.input :setting_show_application, as: :boolean, wrapper: :with_label, recommended: true + .fields-group + = f.input :setting_default_content_type, collection: ['text/plain', 'text/markdown', 'text/html'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_default_content_type_#{item.split('/')[1]}"), content_tag(:span, t("simple_form.hints.defaults.setting_default_content_type_#{item.split('/')[1]}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + %h4= t 'preferences.public_timelines' .fields-group diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 4885878f0..6061e9cfd 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -9,8 +9,8 @@ .fields-row .fields-row__column.fields-group.fields-row__column-6 - = f.input :display_name, wrapper: :with_label, input_html: { maxlength: 30, data: { default: @account.username } }, hint: false - = f.input :note, wrapper: :with_label, input_html: { maxlength: 500 }, hint: false + = f.input :display_name, wrapper: :with_label, input_html: { maxlength: Account::MAX_DISPLAY_NAME_LENGTH, data: { default: @account.username } }, 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/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml index 1148d5ed7..c5a323ee5 100644 --- a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml +++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml @@ -12,5 +12,3 @@ .actions = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit - -= javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous' diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml index 1c0bbf676..28910d3ab 100644 --- a/app/views/shares/show.html.haml +++ b/app/views/shares/show.html.haml @@ -1,5 +1,4 @@ - content_for :header_tags do = render_initial_state - = javascript_pack_tag 'share', crossorigin: 'anonymous' #mastodon-compose{ data: { props: Oj.dump(default_props) } } diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 728e6b9b0..a7c78b997 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -29,7 +29,7 @@ %p< %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: prefers_autoplay?)} %button.status__content__spoiler-link= t('statuses.show_more') - .e-content + .e-content< = Formatter.instance.format(status, custom_emojify: true, autoplay: prefers_autoplay?) - if status.preloadable_poll = render_poll_component(status) diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index 5cd513b32..0e6d4c43d 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -5,7 +5,6 @@ %meta{ name: 'robots', content: 'noindex' }/ %link{ rel: 'alternate', type: 'application/rss+xml', href: tag_url(@tag, format: 'rss') }/ - = javascript_pack_tag 'about', 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 25dee4ee2..601075ea6 100644 --- a/app/workers/activitypub/distribute_poll_update_worker.rb +++ b/app/workers/activitypub/distribute_poll_update_worker.rb @@ -10,7 +10,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/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index b70c7e389..45e6bb88d 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -13,6 +13,8 @@ class FeedInsertWorker when :list @list = List.find(id) @follower = @list.account + when :direct + @account = Account.find(id) end check_and_insert @@ -35,6 +37,8 @@ class FeedInsertWorker FeedManager.instance.filter?(:home, @status, @follower) when :list FeedManager.instance.filter?(:list, @status, @list) + when :direct + FeedManager.instance.filter?(:direct, @status, @account) end end @@ -50,6 +54,8 @@ class FeedInsertWorker FeedManager.instance.push_to_home(@follower, @status) when :list FeedManager.instance.push_to_list(@list, @status) + when :direct + FeedManager.instance.push_to_direct(@account, @status) end end diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb index aa0cc8b8d..78adc97e2 100644 --- a/app/workers/scheduler/feed_cleanup_scheduler.rb +++ b/app/workers/scheduler/feed_cleanup_scheduler.rb @@ -9,6 +9,7 @@ class Scheduler::FeedCleanupScheduler def perform clean_home_feeds! clean_list_feeds! + clean_direct_feeds! end private @@ -21,6 +22,10 @@ class Scheduler::FeedCleanupScheduler feed_manager.clean_feeds!(:list, inactive_list_ids) end + def clean_direct_feeds! + feed_manager.clean_feeds!(:direct, inactive_account_ids) + end + def inactive_account_ids @inactive_account_ids ||= User.confirmed.inactive.pluck(:account_id) end diff --git a/config/environments/production.rb b/config/environments/production.rb index df6b07d77..bf6b5d88e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -115,11 +115,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', - 'Permissions-Policy' => 'interest-cohort=()', + 'Server' => 'Mastodon', + 'X-Frame-Options' => 'DENY', + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'Permissions-Policy' => 'interest-cohort=()', + '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 2dc6f880b..e09f4262b 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -59,6 +59,7 @@ ignore_unused: - 'errors.429' - 'admin.accounts.roles.*' - 'admin.action_logs.actions.*' + - 'themes.*' - 'statuses.attached.*' - 'move_handler.carry_{mutes,blocks}_over_text' diff --git a/config/initializers/0_duplicate_migrations.rb b/config/initializers/0_duplicate_migrations.rb new file mode 100644 index 000000000..6c45e4bd2 --- /dev/null +++ b/config/initializers/0_duplicate_migrations.rb @@ -0,0 +1,52 @@ +# Some migrations have been present in glitch-soc for a long time and have then +# been merged in upstream Mastodon, under a different version number. +# +# This puts us in an uneasy situation in which if we remove upstream's +# migration file, people migrating from upstream will end up having a conflict +# with their already-ran migration. +# +# On the other hand, if we keep upstream's migration and remove our own, +# any current glitch-soc user will have a conflict during migration. +# +# For lack of a better solution, as those migrations are indeed identical, +# we decided monkey-patching Rails' Migrator to completely ignore the duplicate, +# keeping only the one that has run, or an arbitrary one. + +ALLOWED_DUPLICATES = [20180410220657, 20180831171112].freeze + +module ActiveRecord + class Migrator + def self.new(direction, migrations, schema_migration, target_version = nil) + migrated = Set.new(Base.connection.migration_context.get_all_versions) + + migrations.group_by(&:name).each do |name, duplicates| + if duplicates.length > 1 && duplicates.all? { |m| ALLOWED_DUPLICATES.include?(m.version) } + # We have a set of allowed duplicates. Keep the migrated one, if any. + non_migrated = duplicates.reject { |m| migrated.include?(m.version.to_i) } + + if duplicates.length == non_migrated.length || non_migrated.length == 0 + # There weren't any migrated one, so we have to pick one “canonical” migration + migrations = migrations - duplicates[1..-1] + else + # Just reject every duplicate which hasn't been migrated yet + migrations = migrations - non_migrated + end + end + end + + super(direction, migrations, schema_migration, target_version) + end + end + + class MigrationContext + def needs_migration? + # A set of duplicated migrations is considered migrated if at least one of + # them is migrated. + migrated = get_all_versions + migrations.group_by(&:name).each do |name, duplicates| + return true unless duplicates.any? { |m| migrated.include?(m.version.to_i) } + end + return false + end + end +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b377b7b4d..549ac3568 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -2,43 +2,45 @@ # For further information see the following documentation # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy -def host_to_url(str) - "http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}" unless str.blank? -end +if Rails.env.production? + assets_host = Rails.configuration.action_controller.asset_host || "https://#{ENV['WEB_DOMAIN'] || ENV['LOCAL_DOMAIN']}" + data_hosts = [assets_host] -base_host = Rails.configuration.x.web_domain - -assets_host = Rails.configuration.action_controller.asset_host -assets_host ||= host_to_url(base_host) - -media_host = host_to_url(ENV['S3_ALIAS_HOST']) -media_host ||= host_to_url(ENV['S3_CLOUDFRONT_HOST']) -media_host ||= host_to_url(ENV['S3_HOSTNAME']) if ENV['S3_ENABLED'] == 'true' -media_host ||= 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, assets_host - p.media_src :self, :https, :data, assets_host - p.frame_src :self, :https - p.manifest_src :self, assets_host - - if Rails.env.development? - webpacker_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{Webpacker.dev_server.host_with_port}" } - - p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url, *webpacker_urls - p.script_src :self, :unsafe_inline, :unsafe_eval, assets_host - p.child_src :self, :blob, assets_host - p.worker_src :self, :blob, 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 - p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url - p.script_src :self, assets_host - p.child_src :self, :blob, assets_host - p.worker_src :self, :blob, assets_host + attachments_host = nil + end + + data_hosts << attachments_host unless attachments_host.nil? + + if ENV['PAPERCLIP_ROOT_URL'] + url = Addressable::URI.parse(assets_host) + ENV['PAPERCLIP_ROOT_URL'] + data_hosts << "https://#{url.host}" + end + + data_hosts.concat(ENV['EXTRA_DATA_HOSTS'].split('|')) if ENV['EXTRA_DATA_HOSTS'] + + 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, assets_host + p.media_src :self, :data, *data_hosts + p.frame_src :self, :https + p.child_src :self, :blob, assets_host + p.worker_src :self, :blob, assets_host + p.connect_src :self, :blob, :data, 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/locale.rb b/config/initializers/locale.rb new file mode 100644 index 000000000..fed182a71 --- /dev/null +++ b/config/initializers/locale.rb @@ -0,0 +1,7 @@ +# 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] +I18n.load_path += Dir[Rails.root.join('config', 'locales-glitch', '*.{rb,yml}').to_s] diff --git a/config/locales-glitch/en.yml b/config/locales-glitch/en.yml new file mode 100644 index 000000000..6268727a7 --- /dev/null +++ b/config/locales-glitch/en.yml @@ -0,0 +1,25 @@ +--- +en: + admin: + dashboard: + keybase: Keybase integration + settings: + enable_keybase: + desc_html: Allow your users to prove their identity via keybase + title: Enable keybase integration + outgoing_spoilers: + desc_html: When federating toots, add this content warning to toots that do not have one. It is useful if your server is specialized in content other servers might want to have under a Content Warning. Media will also be marked as sensitive. + title: Content warning for outgoing toots + hide_followers_count: + desc_html: Do not show followers count on user profiles + title: Hide followers count + 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 + generic: + use_this: Use this + settings: + flavours: Flavours diff --git a/config/locales-glitch/es.yml b/config/locales-glitch/es.yml new file mode 100644 index 000000000..7e8de1560 --- /dev/null +++ b/config/locales-glitch/es.yml @@ -0,0 +1,25 @@ +--- +es: + admin: + dashboard: + keybase: Integración con keybase + settings: + enable_keybase: + desc_html: Permite a tus usuarixs comprobar su identidad por medio de keybase + title: Habilitar la integración con keybase + outgoing_spoilers: + desc_html: Cuando los toots federen, agrega esta etiqueta de contenido a los toots que no tengan. Es útil si tu servidor se especializa en contenido que otros servidores desearían tener con una advertencia de contenido. Los medios también se marcarán como sensibles. + title: Advertencia de contenido para los toots salientes + hide_followers_count: + desc_html: No mostrar el conteo de seguidorxs en perfiles de usuarix + title: Ocultar conteo de seguidorxs + show_reblogs_in_public_timelines: + desc_html: Mostrar retoots públicos en las línea de tiempo local y pública. + title: Mostrar retoots en líneas de tiempo públicas + show_replies_in_public_timelines: + desc_html: Además de auto-respuestas públicas (hilos), mostrar respuestas públicas en las línea de tiempo local y pública. + title: Mostrar respuestas en líneas de tiempo públicas + generic: + use_this: Usar + settings: + flavours: Ediciones \ No newline at end of file diff --git a/config/locales-glitch/ja.yml b/config/locales-glitch/ja.yml new file mode 100644 index 000000000..15fb6e566 --- /dev/null +++ b/config/locales-glitch/ja.yml @@ -0,0 +1,6 @@ +--- +ja: + generic: + use_this: これを使う + settings: + flavours: フレーバー diff --git a/config/locales-glitch/pl.yml b/config/locales-glitch/pl.yml new file mode 100644 index 000000000..3fcdedcf3 --- /dev/null +++ b/config/locales-glitch/pl.yml @@ -0,0 +1,6 @@ +--- +pl: + generic: + use_this: Użyj tego + settings: + flavours: Odmiany diff --git a/config/locales-glitch/simple_form.en.yml b/config/locales-glitch/simple_form.en.yml new file mode 100644 index 000000000..612943571 --- /dev/null +++ b/config/locales-glitch/simple_form.en.yml @@ -0,0 +1,20 @@ +--- +en: + simple_form: + hints: + defaults: + setting_default_content_type_html: When writing toots, assume they are written in raw HTML, unless specified otherwise + setting_default_content_type_markdown: When writing toots, assume they are using Markdown for rich text formatting, unless specified otherwise + setting_default_content_type_plain: When writing toots, assume they are plain text with no special formatting, unless specified otherwise (default Mastodon behavior) + setting_default_language: The language of your toots can be detected automatically, but it's not always accurate + setting_skin: Reskins the selected Mastodon flavour + labels: + defaults: + setting_default_content_type: Default format for toots + setting_default_content_type_html: HTML + setting_default_content_type_markdown: Markdown + setting_default_content_type_plain: Plain text + setting_favourite_modal: Show confirmation dialog before favouriting (applies to Glitch flavour only) + setting_hide_followers_count: Hide your followers count + setting_skin: Skin + setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only) diff --git a/config/locales-glitch/simple_form.es.yml b/config/locales-glitch/simple_form.es.yml new file mode 100644 index 000000000..977775be6 --- /dev/null +++ b/config/locales-glitch/simple_form.es.yml @@ -0,0 +1,20 @@ +--- +es: + simple_form: + hints: + defaults: + setting_default_content_type_html: Al escribir toots, asume que estás escritos en HTML, a menos que se especifique lo contrario + setting_default_content_type_markdown: Al escribir toots, asume que estás usando Markdown para dar formato de texto enriquecido, a menos que se especifique lo contrario + setting_default_content_type_plain: Al escribir toots, asume que estás usando texto sin formato, a menos que se especifique lo contrario (predeterminado de Mastodon) + setting_default_language: El idioma de tus toots se puede detectar automáticamente, pero no siempre es correcto + setting_skin: Cambia el diseño de la edición seleccionada de Mastodon + labels: + defaults: + setting_default_content_type: Formato predeterminado de tus toots + setting_default_content_type_html: HTML + setting_default_content_type_markdown: Markdown + setting_default_content_type_plain: Sin formato + setting_favourite_modal: Mostrar diálogo de confirmación antes de marcar como favorito (sólo aplica a la edición Glich) + setting_hide_followers_count: Ocultar tu conteo de seguidorxs + setting_skin: Diseño + setting_system_emoji_font: Usar la fuente predeterminada del sistema para emojis (sólo aplica a la edición Glitch) diff --git a/config/locales-glitch/simple_form.ja.yml b/config/locales-glitch/simple_form.ja.yml new file mode 100644 index 000000000..ba02c8091 --- /dev/null +++ b/config/locales-glitch/simple_form.ja.yml @@ -0,0 +1,6 @@ +--- +ja: + simple_form: + labels: + defaults: + setting_favourite_modal: お気に入りをする前に確認ダイアログを表示する diff --git a/config/locales-glitch/simple_form.pl.yml b/config/locales-glitch/simple_form.pl.yml new file mode 100644 index 000000000..264494c2d --- /dev/null +++ b/config/locales-glitch/simple_form.pl.yml @@ -0,0 +1,10 @@ +--- +pl: + simple_form: + hints: + defaults: + setting_skin: Zmienia wygląd używanej odmiany Mastodona + labels: + defaults: + setting_favourite_modal: Pytaj o potwierdzenie przed dodaniem do ulubionych + setting_skin: Motyw diff --git a/config/navigation.rb b/config/navigation.rb index b3462c48d..c626b09ee 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -16,6 +16,12 @@ SimpleNavigation::Configuration.run do |navigation| s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url end + 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, if: -> { current_user.functional? } n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? } diff --git a/config/routes.rb b/config/routes.rb index 2373d8a51..cabc0f0a0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,6 +154,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, :create] @@ -345,6 +347,7 @@ Rails.application.routes.draw do end namespace :timelines do + resource :direct, only: :show, controller: :direct resource :home, only: :show, controller: :home resource :public, only: :show, controller: :public resources :tag, only: :show @@ -427,9 +430,10 @@ Rails.application.routes.draw do end end - resources :notifications, only: [:index, :show] do + resources :notifications, only: [:index, :show, :destroy] do collection do post :clear + delete :destroy_multiple end member do diff --git a/config/settings.yml b/config/settings.yml index 06cee2532..953e7b3eb 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: 'Mastodon Glitch Edition' site_short_description: '' site_description: '' site_extended_description: '' @@ -13,13 +13,14 @@ 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' @@ -27,10 +28,14 @@ defaults: &defaults preview_sensitive_media: false reduce_motion: false disable_swiping: false - show_application: true + show_application: false system_font_ui: false + system_emoji_font: false noindex: false - theme: 'default' + hide_followers_count: false + enable_keybase: true + flavour: 'glitch' + skin: 'default' aggregate_reblogs: true advanced_layout: false use_blurhash: true @@ -66,8 +71,12 @@ 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 + default_content_type: 'text/plain' show_domain_blocks: 'disabled' show_domain_blocks_rationale: 'disabled' + outgoing_spoilers: '' require_invite_text: false development: 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 25b6b7abd..f05c888d5 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 { load } = require('js-yaml'); -const { readFileSync } = require('fs'); +const { lstatSync, readFileSync } = require('fs'); +const glob = require('glob'); const configPath = resolve('config', 'webpacker.yml'); const settings = load(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 = load(readFileSync(themePath), 'utf8'); +const core = function () { + const coreFile = resolve('app', 'javascript', 'core', 'theme.yml'); + const data = load(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 = load(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 }; + } +} const output = { path: resolve('public', settings.public_output_path), @@ -18,7 +64,8 @@ const output = { module.exports = { settings, - themes, + core, + flavours, env: { NODE_ENV: env.NODE_ENV, PUBLIC_OUTPUT_PATH: settings.public_output_path, 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 bc1f42c13..6ecfb3164 100644 --- a/config/webpack/rules/css.js +++ b/config/webpack/rules/css.js @@ -20,6 +20,9 @@ module.exports = { { loader: 'sass-loader', options: { + sassOptions: { + includePaths: ['app/javascript'], + }, implementation: require('sass'), sourceMap: true, }, diff --git a/config/webpack/shared.js b/config/webpack/shared.js index 05828aebe..ce08ac206 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..66411ba1d --- /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 + + safety_assured { 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..bc79022e4 --- /dev/null +++ b/db/migrate/20180410220657_create_bookmarks.rb @@ -0,0 +1,20 @@ +# This migration is a duplicate of 20180831171112 and may get ignored, see +# config/initializers/0_duplicate_migrations.rb + +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 + + safety_assured do + add_foreign_key :bookmarks, :accounts, column: :account_id, on_delete: :cascade + add_foreign_key :bookmarks, :statuses, column: :status_id, on_delete: :cascade + end + + 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..067c53357 --- /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 + + safety_assured { 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/20180831171112_create_bookmarks.rb b/db/migrate/20180831171112_create_bookmarks.rb index 27c7339c9..5d587b7e9 100644 --- a/db/migrate/20180831171112_create_bookmarks.rb +++ b/db/migrate/20180831171112_create_bookmarks.rb @@ -1,3 +1,6 @@ +# This migration is a duplicate of 20180410220657 and may get ignored, see +# config/initializers/0_duplicate_migrations.rb + class CreateBookmarks < ActiveRecord::Migration[5.1] def change create_table :bookmarks do |t| diff --git a/db/migrate/20190512200918_add_content_type_to_statuses.rb b/db/migrate/20190512200918_add_content_type_to_statuses.rb new file mode 100644 index 000000000..efbe2caa7 --- /dev/null +++ b/db/migrate/20190512200918_add_content_type_to_statuses.rb @@ -0,0 +1,5 @@ +class AddContentTypeToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :content_type, :string + 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 3fcee2d30..161dfdf0c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -812,7 +812,9 @@ ActiveRecord::Schema.define(version: 2021_05_26_193025) 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.string "content_type" t.datetime "deleted_at" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" diff --git a/lib/mastodon/statuses_cli.rb b/lib/mastodon/statuses_cli.rb index b9dccdd8a..8a18a3b2f 100644 --- a/lib/mastodon/statuses_cli.rb +++ b/lib/mastodon/statuses_cli.rb @@ -50,7 +50,7 @@ module Mastodon # Skip statuses favourited by local users scope = scope.where('id NOT IN (SELECT favourites.status_id FROM favourites WHERE statuses.id = favourites.status_id AND favourites.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))') # Skip statuses bookmarked by local users - scope = scope.where('id NOT IN (SELECT bookmarks.status_id FROM bookmarks WHERE statuses.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))') + scope = scope.where('id NOT IN (SELECT bookmarks.status_id FROM bookmarks WHERE statuses.id = bookmarks.status_id)') unless options[:clean_followed] # Skip accounts followed by local accounts diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 287cdc7fd..00762f342 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -21,7 +21,7 @@ module Mastodon end def suffix - '' + '+glitch' end def to_a @@ -33,7 +33,7 @@ module Mastodon 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/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb index a2e1d9d01..ecaec2f84 100644 --- a/lib/sanitize_ext/sanitize_config.rb +++ b/lib/sanitize_ext/sanitize_config.rb @@ -36,6 +36,37 @@ class Sanitize node['class'] = class_list.join(' ') end + IMG_TAG_TRANSFORMER = lambda do |env| + node = env[:node] + + return unless env[:node_name] == 'img' + + node.name = 'a' + + node['href'] = node['src'] + if node['alt'].present? + node.content = "[🖼 #{node['alt']}]" + else + url = node['href'] + prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s + text = url[prefix.length, 30] + text = text + "…" if url[prefix.length..-1].length > 30 + node.content = "[🖼 #{text}]" + end + end + + LINK_REL_TRANSFORMER = lambda do |env| + return unless env[:node_name] == 'a' and env[:node]['href'] + + node = env[:node] + + rel = (node['rel'] || '').split(' ') & ['tag'] + unless env[:config][:outgoing] && TagManager.instance.local_url?(node['href']) + rel += ['nofollow', 'noopener', 'noreferrer'] + end + node['rel'] = rel.join(' ') + end + UNSUPPORTED_HREF_TRANSFORMER = lambda do |env| return unless env[:node_name] == 'a' @@ -52,45 +83,34 @@ class Sanitize current_node.replace(current_node.text) unless LINK_PROTOCOLS.include?(scheme) end - UNSUPPORTED_ELEMENTS_TRANSFORMER = lambda do |env| - return unless %w(h1 h2 h3 h4 h5 h6 blockquote pre ul ol li).include?(env[:node_name]) - - current_node = env[:node] - - case env[:node_name] - when 'li' - current_node.traverse do |node| - next unless %w(p ul ol li).include?(node.name) - - node.add_next_sibling('<br>') if node.next_sibling - node.replace(node.children) unless node.text? - end - else - current_node.name = 'p' - end - 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 sup 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), + 'ol' => %w(start reversed), + 'li' => %w(value), }, add_attributes: { 'a' => { - 'rel' => 'nofollow noopener noreferrer', 'target' => '_blank', }, }, - protocols: {}, + protocols: { + 'a' => { 'href' => LINK_PROTOCOLS }, + 'blockquote' => { 'cite' => LINK_PROTOCOLS }, + }, transformers: [ CLASS_WHITELIST_TRANSFORMER, - UNSUPPORTED_ELEMENTS_TRANSFORMER, + IMG_TAG_TRANSFORMER, UNSUPPORTED_HREF_TRANSFORMER, + LINK_REL_TRANSFORMER, ] ) 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 f485b1370..b97c131e9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "<rootDir>/config/", "<rootDir>/log/", "<rootDir>/public/", - "<rootDir>/tmp/" + "<rootDir>/tmp/", + "<rootDir>/app/javascript/themes/" ], "setupFiles": [ "raf/polyfill" @@ -70,6 +71,7 @@ "@github/webauthn-json": "^0.5.7", "@rails/ujs": "^6.1.3", "array-includes": "^3.1.3", + "atrament": "0.2.4", "arrow-key-navigation": "^1.2.0", "autoprefixer": "^9.8.6", "axios": "^0.21.1", @@ -93,6 +95,7 @@ "escape-html": "^1.0.3", "exif-js": "^2.3.0", "express": "^4.17.1", + "favico.js": "^0.3.10", "file-loader": "^6.2.0", "font-awesome": "^4.7.0", "glob": "^7.1.7", 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 9fb0d8770..1b29772c3 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -74,7 +74,9 @@ describe Api::V1::Accounts::CredentialsController do describe 'with invalid data' do before do - patch :update, params: { note: 'This is too long. ' * 30 } + 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/timelines/direct_controller_spec.rb b/spec/controllers/api/v1/timelines/direct_controller_spec.rb new file mode 100644 index 000000000..a22c2cbea --- /dev/null +++ b/spec/controllers/api/v1/timelines/direct_controller_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Timelines::DirectController, type: :controller do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') } + + describe 'GET #show' do + it 'returns 200' do + allow(controller).to receive(:doorkeeper_token) { token } + get :show + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/api/v1/timelines/public_controller_spec.rb b/spec/controllers/api/v1/timelines/public_controller_spec.rb index 737aedba6..b8e9d8674 100644 --- a/spec/controllers/api/v1/timelines/public_controller_spec.rb +++ b/spec/controllers/api/v1/timelines/public_controller_spec.rb @@ -44,6 +44,10 @@ describe Api::V1::Timelines::PublicController do context 'without a user context' do let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil) } + before do + Setting.timeline_preview = true + end + describe 'GET #show' do it 'returns http success' do get :show diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 458298a6b..881ecb124 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -73,37 +73,41 @@ 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' allow(Setting).to receive(:[]).with('noindex').and_return false - 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/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb new file mode 100644 index 000000000..1e41f6ed7 --- /dev/null +++ b/spec/controllers/health_controller_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +describe HealthController do + render_views + + describe 'GET #show' do + subject(:response) { get :show, params: { format: :json } } + + it 'returns the right response' do + expect(response).to have_http_status 200 + end + 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/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/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 2703c18f3..3e9cbba92 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -240,6 +240,31 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'limited when direct message assertion is false' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + directMessage: false, + to: ActivityPub::TagManager.instance.uri_for(recipient), + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(recipient), + }, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'limited' + end + end + context 'direct' do let(:recipient) { Fabricate(:account) } @@ -264,6 +289,27 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'direct when direct message assertion is true' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + directMessage: true, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + context 'as a reply' do let(:original_status) { Fabricate(:status) } diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 0df85e5bc..2217ad272 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -120,6 +120,13 @@ RSpec.describe FeedManager do expect(FeedManager.instance.filter?(:home, status, bob)).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)).to be true + end + it 'returns true for reblog of a personally blocked domain' do alice.block_domain!('example.com') alice.follow!(jeff) diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index 5c88a2569..73cb39550 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -344,11 +344,22 @@ RSpec.describe Formatter do end context do + let(:content_type) { 'text/plain' } + subject do - status = Fabricate(:status, text: text, uri: nil) + status = Fabricate(:status, text: text, content_type: content_type, uri: nil) Formatter.instance.format(status) end + context 'given an invalid URL (invalid port)' do + let(:text) { 'https://foo.bar:X/' } + let(:content_type) { 'text/markdown' } + + it 'outputs the raw URL' do + is_expected.to eq '<p>https://foo.bar:X/</p>' + end + end + include_examples 'encode and link URLs' end @@ -472,7 +483,8 @@ RSpec.describe Formatter do subject { Formatter.instance.plaintext(status) } context 'given a post with local status' do - let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) } + let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', content_type: content_type, uri: nil) } + let(:content_type) { 'text/plain' } it 'returns the raw text' do is_expected.to eq '<p>a text by a nerd who uses an HTML tag in text</p>' diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb index 747d81158..8bcffb2e5 100644 --- a/spec/lib/sanitize_config_spec.rb +++ b/spec/lib/sanitize_config_spec.rb @@ -3,27 +3,17 @@ require 'rails_helper' describe Sanitize::Config do - describe '::MASTODON_STRICT' do - subject { Sanitize::Config::MASTODON_STRICT } - - it 'converts h1 to p' do - expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p>Foo</p>' - end - - it 'converts ul to p' do - expect(Sanitize.fragment('<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p>Foo<br>Bar</p>' - end - - it 'converts p inside ul' do - expect(Sanitize.fragment('<ul><li><p>Foo</p><p>Bar</p></li><li>Baz</li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>' + shared_examples 'common HTML sanitization' do + it 'keeps h1' do + expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<h1>Foo</h1>' end - it 'converts ul inside ul' do - expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>' + it 'keeps ul' do + expect(Sanitize.fragment('<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>' end - it 'keep links in lists' do - expect(Sanitize.fragment('<p>Check out:</p><ul><li><a href="https://joinmastodon.org" rel="nofollow noopener noreferrer" target="_blank">joinmastodon.org</a></li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p><a href="https://joinmastodon.org" rel="nofollow noopener noreferrer" target="_blank">joinmastodon.org</a><br>Bar</p>' + it 'keeps start and reversed attributes of ol' do + expect(Sanitize.fragment('<p>Check out:</p><ol start="3" reversed=""><li>Foo</li><li>Bar</li></ol>', subject)).to eq '<p>Check out:</p><ol start="3" reversed=""><li>Foo</li><li>Bar</li></ol>' end it 'removes a without href' do @@ -41,5 +31,40 @@ describe Sanitize::Config do it 'keeps a with href' do expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>' end + + it 'removes a with unparsable href' do + expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test' + end + + it 'keeps a with supported scheme and no host' do + expect(Sanitize.fragment('<a href="dweb:/a/foo">Test</a>', subject)).to eq '<a href="dweb:/a/foo" rel="nofollow noopener noreferrer" target="_blank">Test</a>' + end + end + + describe '::MASTODON_STRICT' do + subject { Sanitize::Config::MASTODON_STRICT } + + it_behaves_like 'common HTML sanitization' + + it 'keeps a with href and rel tag' do + expect(Sanitize.fragment('<a href="http://example.com" rel="tag">Test</a>', subject)).to eq '<a href="http://example.com" rel="tag nofollow noopener noreferrer" target="_blank">Test</a>' + end + end + + describe '::MASTODON_STRICT with outgoing toots' do + subject { Sanitize::Config::MASTODON_STRICT.merge(outgoing: true) } + + around do |example| + original_web_domain = Rails.configuration.x.web_domain + example.run + Rails.configuration.x.web_domain = original_web_domain + end + + it_behaves_like 'common HTML sanitization' + + it 'keeps a with href and rel tag, not adding to rel if url is local' do + Rails.configuration.x.web_domain = 'domain.test' + expect(Sanitize.fragment('<a href="http://domain.test/tags/foo" rel="tag">Test</a>', subject)).to eq '<a href="http://domain.test/tags/foo" rel="tag" target="_blank">Test</a>' + end end end diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index 85fbf7e79..db959280c 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, notify: false }) 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, notify: false }) + end + end + context 'account without Follow' do it 'returns {}' do is_expected.to eq({}) @@ -612,7 +619,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 36ce8ee60..b0e854f09 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/public_feed_spec.rb b/spec/models/public_feed_spec.rb index 0392a582c..c251953a4 100644 --- a/spec/models/public_feed_spec.rb +++ b/spec/models/public_feed_spec.rb @@ -47,6 +47,7 @@ RSpec.describe PublicFeed, type: :model do let!(:remote_account) { Fabricate(:account, domain: 'test.com') } let!(:local_status) { Fabricate(:status, account: local_account) } let!(:remote_status) { Fabricate(:status, account: remote_account) } + let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) } subject { described_class.new(viewer).get(20).map(&:id) } @@ -60,6 +61,10 @@ RSpec.describe PublicFeed, type: :model do it 'includes local statuses' do expect(subject).to include(local_status.id) end + + it 'does not include local-only statuses' do + expect(subject).not_to include(local_only_status.id) + end end context 'with a viewer' do @@ -72,6 +77,54 @@ RSpec.describe PublicFeed, type: :model do it 'includes local statuses' do expect(subject).to include(local_status.id) end + + it 'does not include local-only statuses' do + expect(subject).not_to include(local_only_status.id) + end + end + end + + context 'without local_only option but allow_local_only' do + let(:viewer) { nil } + + let!(:local_account) { Fabricate(:account, domain: nil) } + let!(:remote_account) { Fabricate(:account, domain: 'test.com') } + let!(:local_status) { Fabricate(:status, account: local_account) } + let!(:remote_status) { Fabricate(:status, account: remote_account) } + let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) } + + subject { described_class.new(viewer, allow_local_only: true).get(20).map(&:id) } + + context 'without a viewer' do + let(:viewer) { nil } + + it 'includes remote instances statuses' do + expect(subject).to include(remote_status.id) + end + + it 'includes local statuses' do + expect(subject).to include(local_status.id) + end + + it 'does not include local-only statuses' do + expect(subject).not_to include(local_only_status.id) + end + end + + context 'with a viewer' do + let(:viewer) { Fabricate(:account, username: 'viewer') } + + it 'includes remote instances statuses' do + expect(subject).to include(remote_status.id) + end + + it 'includes local statuses' do + expect(subject).to include(local_status.id) + end + + it 'includes local-only statuses' do + expect(subject).to include(local_only_status.id) + end end end @@ -80,6 +133,7 @@ RSpec.describe PublicFeed, type: :model do let!(:remote_account) { Fabricate(:account, domain: 'test.com') } let!(:local_status) { Fabricate(:status, account: local_account) } let!(:remote_status) { Fabricate(:status, account: remote_account) } + let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) } subject { described_class.new(viewer, local: true).get(20).map(&:id) } @@ -90,6 +144,10 @@ RSpec.describe PublicFeed, type: :model do expect(subject).to include(local_status.id) expect(subject).not_to include(remote_status.id) end + + it 'does not include local-only statuses' do + expect(subject).not_to include(local_only_status.id) + end end context 'with a viewer' do @@ -105,6 +163,10 @@ RSpec.describe PublicFeed, type: :model do expect(subject).to include(local_status.id) expect(subject).not_to include(remote_status.id) end + + it 'includes local-only statuses' do + expect(subject).to include(local_only_status.id) + end end end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 20fb894e7..c1375ea94 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -203,6 +203,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) } @@ -267,6 +304,56 @@ RSpec.describe Status, type: :model do end end + describe '.as_direct_timeline' do + let(:account) { Fabricate(:account) } + let(:followed) { Fabricate(:account) } + let(:not_followed) { Fabricate(:account) } + + before do + Fabricate(:follow, account: account, target_account: followed) + + @self_public_status = Fabricate(:status, account: account, visibility: :public) + @self_direct_status = Fabricate(:status, account: account, visibility: :direct) + @followed_public_status = Fabricate(:status, account: followed, visibility: :public) + @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct) + @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct) + + @results = Status.as_direct_timeline(account) + end + + it 'does not include public statuses from self' do + expect(@results).to_not include(@self_public_status) + end + + it 'includes direct statuses from self' do + expect(@results).to include(@self_direct_status) + end + + it 'does not include public statuses from followed' do + expect(@results).to_not include(@followed_public_status) + end + + it 'does not include direct statuses not mentioning recipient from followed' do + expect(@results).to_not include(@followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from non-followed' do + expect(@results).to_not include(@not_followed_direct_status) + end + + it 'includes direct statuses mentioning recipient from followed' do + Fabricate(:mention, account: account, status: @followed_direct_status) + results2 = Status.as_direct_timeline(account) + expect(results2).to include(@followed_direct_status) + end + + it 'includes direct statuses mentioning recipient from non-followed' do + Fabricate(:mention, account: account, status: @not_followed_direct_status) + results2 = Status.as_direct_timeline(account) + expect(results2).to include(@not_followed_direct_status) + end + end + describe '.permitted_for' do subject { described_class.permitted_for(target_account, account).pluck(:visibility) } diff --git a/spec/models/tag_feed_spec.rb b/spec/models/tag_feed_spec.rb index 17d88eb99..76277c467 100644 --- a/spec/models/tag_feed_spec.rb +++ b/spec/models/tag_feed_spec.rb @@ -64,5 +64,19 @@ describe TagFeed, type: :service do results = described_class.new(tag1, nil).get(20) expect(results).to include(status) end + + context 'on a local-only status' do + let!(:status) { Fabricate(:status, tags: [tag1], local_only: true) } + + it 'does not show local-only statuses without a viewer' do + results = described_class.new(tag1, nil).get(20) + expect(results).to_not include(status) + end + + it 'shows local-only statuses given a viewer' do + results = described_class.new(tag1, account).get(20) + expect(results).to include(status) + end + end end end 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 f2cb22c5e..118436f8b 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -48,7 +48,7 @@ RSpec.describe NotifyService, type: :service do recipient.suspend! 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)) } let(:type) { :mention } diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb index bef3f29f5..643ea6d22 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) @@ -51,7 +56,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 7bb645a13..ac8e80fb9 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -357,6 +357,7 @@ const startWorker = (workerId) => { const channelNameFromPath = req => { const { path, query } = req; const onlyMedia = isTruthy(query.only_media); + const allowLocalOnly = isTruthy(query.allow_local_only); switch(path) { case '/api/v1/streaming/user': @@ -581,9 +582,10 @@ const startWorker = (workerId) => { * @param {function(string[], function(string): void): void} attachCloseHandler * @param {boolean=} needsFiltering * @param {boolean=} notificationOnly + * @param {boolean=} allowLocalOnly * @return {function(string): void} */ - const streamFrom = (ids, req, output, attachCloseHandler, needsFiltering = false, notificationOnly = false) => { + const streamFrom = (ids, req, output, attachCloseHandler, needsFiltering = false, notificationOnly = false, allowLocalOnly = false) => { const accountId = req.accountId || req.remoteAddress; const streamType = notificationOnly ? ' (notification)' : ''; @@ -613,6 +615,12 @@ const startWorker = (workerId) => { return; } + // Only send local-only statuses to logged-in users + if (event === 'update' && payload.local_only && !(req.accountId && allowLocalOnly)) { + 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') { @@ -759,7 +767,7 @@ const startWorker = (workerId) => { const onSend = streamToHttp(req, res); const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds)); - streamFrom(channelIds, req, onSend, onEnd, options.needsFiltering, options.notificationOnly); + streamFrom(channelIds, req, onSend, onEnd, options.needsFiltering, options.notificationOnly, options.allowLocalOnly); }).catch(err => { log.verbose(req.requestId, 'Subscription error:', err.toString()); httpNotFound(res); @@ -786,63 +794,77 @@ const startWorker = (workerId) => { case 'user': resolve({ channelIds: req.deviceId ? [`timeline:${req.accountId}`, `timeline:${req.accountId}:${req.deviceId}`] : [`timeline:${req.accountId}`], - options: { needsFiltering: false, notificationOnly: false }, + options: { needsFiltering: false, notificationOnly: false, allowLocalOnly: true }, }); break; case 'user:notification': resolve({ channelIds: [`timeline:${req.accountId}`], - options: { needsFiltering: false, notificationOnly: true }, + options: { needsFiltering: false, notificationOnly: true, allowLocalOnly: true }, }); break; case 'public': resolve({ channelIds: ['timeline:public'], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: isTruthy(params.allow_local_only) }, + }); + + break; + case 'public:allow_local_only': + resolve({ + channelIds: ['timeline:public'], + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: true }, }); break; case 'public:local': resolve({ channelIds: ['timeline:public:local'], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: true }, }); break; case 'public:remote': resolve({ channelIds: ['timeline:public:remote'], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: false }, }); break; case 'public:media': resolve({ channelIds: ['timeline:public:media'], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: isTruthy(query.allow_local_only) }, + }); + + break; + case 'public:allow_local_only:media': + resolve({ + channelIds: ['timeline:public:media'], + options: { needsFiltering: true, notificationsOnly: false, allowLocalOnly: true }, }); break; case 'public:local:media': resolve({ channelIds: ['timeline:public:local:media'], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: true }, }); break; case 'public:remote:media': resolve({ channelIds: ['timeline:public:remote:media'], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: false }, }); break; case 'direct': resolve({ channelIds: [`timeline:direct:${req.accountId}`], - options: { needsFiltering: false, notificationOnly: false }, + options: { needsFiltering: false, notificationOnly: false, allowLocalOnly: true }, }); break; @@ -852,7 +874,7 @@ const startWorker = (workerId) => { } else { resolve({ channelIds: [`timeline:hashtag:${params.tag.toLowerCase()}`], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: true }, }); } @@ -863,7 +885,7 @@ const startWorker = (workerId) => { } else { resolve({ channelIds: [`timeline:hashtag:${params.tag.toLowerCase()}:local`], - options: { needsFiltering: true, notificationOnly: false }, + options: { needsFiltering: true, notificationOnly: false, allowLocalOnly: true }, }); } @@ -872,7 +894,7 @@ const startWorker = (workerId) => { authorizeListAccess(params.list, req).then(() => { resolve({ channelIds: [`timeline:list:${params.list}`], - options: { needsFiltering: false, notificationOnly: false }, + options: { needsFiltering: false, notificationOnly: false, allowLocalOnly: true }, }); }).catch(() => { reject('Not authorized to stream this list'); @@ -919,7 +941,7 @@ const startWorker = (workerId) => { const onSend = streamToWs(request, socket, streamNameFromChannelName(channelName, params)); const stopHeartbeat = subscriptionHeartbeat(channelIds); - const listener = streamFrom(channelIds, request, onSend, undefined, options.needsFiltering, options.notificationOnly); + const listener = streamFrom(channelIds, request, onSend, undefined, options.needsFiltering, options.notificationOnly, options.allowLocalOnly); subscriptions[channelIds.join(';')] = { listener, diff --git a/yarn.lock b/yarn.lock index b8ea0f369..3a1c439b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2244,6 +2244,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atrament@0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.4.tgz#6f78196edfcd194e568b7c0b9c88201ec371ac66" + integrity sha512-hSA9VwW6COMwvRhSEO4uZweZ91YGOdHqwvslNyrJZG+8mzc4qx/qMsDZBuAeXFeWZO/QKtRjIXguOUy1aNMl3A== + autoprefixer@^9.8.6: version "9.8.6" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" @@ -4807,6 +4812,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: 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" + integrity sha1-gFhuJ6EX8kqNUcGKmb3HFNQzkwE= + faye-websocket@^0.11.3: version "0.11.3" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e" |