diff --git a/.env.vagrant b/.env.vagrant new file mode 100644 index 0000000000000000000000000000000000000000..0ab0552c94c9cad2727bcf5b81da1f66f3a2e94c --- /dev/null +++ b/.env.vagrant @@ -0,0 +1 @@ +VAGRANT=true \ No newline at end of file diff --git a/.gitignore b/.gitignore index a60603c7d42af9ba904a82829cc7e9b1a6848901..7f51045aa0bbc4aab62757a4bd54f3492728638f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ public/assets .env.production node_modules/ neo4j/ + +# Ignore Vagrant files +.vagrant/ diff --git a/.rubocop.yml b/.rubocop.yml index 28c7359130e1ff918bfaf2b4405c1c08e8ee009a..ab28c0fe1b0092e97508cdd7778e56fea05b5b07 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -87,3 +87,4 @@ AllCops: - 'bin/*' - 'Rakefile' - 'node_modules/**/*' + - 'Vagrantfile' diff --git a/Gemfile b/Gemfile index 6bf95ec5eeaf7cb5f6109694c00e4c266dcd2b08..7fb3ab91de1a046c681a06773bb4c9b369f23d04 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ # frozen_string_literal: true source 'https://rubygems.org' +ruby '2.3.1' gem 'rails', '~> 5.0.1.0' gem 'sass-rails', '~> 5.0' @@ -16,8 +17,9 @@ gem 'pg' gem 'pghero' gem 'dotenv-rails' gem 'font-awesome-rails' +gem 'best_in_place', '~> 3.0.1' -gem 'paperclip', '~> 5.0' +gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder' gem 'aws-sdk', '>= 2.0' @@ -29,7 +31,6 @@ gem 'link_header' gem 'ostatus2' gem 'goldfinger' gem 'devise' -gem 'rails_autolink' gem 'doorkeeper' gem 'rabl' gem 'oj' @@ -42,9 +43,11 @@ gem 'will_paginate' gem 'rack-attack' gem 'rack-cors', require: 'rack/cors' gem 'sidekiq' -gem 'ledermann-rails-settings' +gem 'rails-settings-cached' gem 'pg_search' gem 'simple-navigation' +gem 'statsd-instrument' +gem 'ruby-oembed', require: 'oembed' gem 'react-rails' gem 'browserify-rails' @@ -69,6 +72,7 @@ group :development do gem 'better_errors' gem 'binding_of_caller' gem 'letter_opener' + gem 'letter_opener_web' gem 'bullet' gem 'active_record_query_trace' end diff --git a/Gemfile.lock b/Gemfile.lock index 2467b76ccdeb09f1e83040b8fdb654cb0e726f18..12f6679c7fe4d3a84ad48c673005be712b705bb0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,6 +60,9 @@ GEM babel-source (>= 4.0, < 6) execjs (~> 2.0) bcrypt (3.1.11) + best_in_place (3.0.3) + actionpack (>= 3.2) + railties (>= 3.2) better_errors (2.1.1) coderay (>= 1.0.0) erubis (>= 2.6.6) @@ -73,8 +76,7 @@ GEM bullet (5.3.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) - climate_control (0.0.3) - activesupport (>= 3.0) + climate_control (0.1.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) coderay (1.1.1) @@ -86,7 +88,7 @@ GEM execjs coffee-script-source (1.10.0) colorize (0.8.1) - concurrent-ruby (1.0.3) + concurrent-ruby (1.0.4) connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) @@ -172,10 +174,12 @@ GEM json (1.8.3) launchy (2.4.3) addressable (~> 2.3) - ledermann-rails-settings (2.4.2) - activerecord (>= 3.1) letter_opener (1.4.1) launchy (~> 2.2) + letter_opener_web (1.3.0) + actionmailer (>= 3.2) + letter_opener (~> 1.0) + railties (>= 3.2) link_header (0.0.8) lograge (0.4.1) actionpack (>= 4, < 5.1) @@ -259,11 +263,11 @@ GEM nokogiri (~> 1.6.0) rails-html-sanitizer (1.0.3) loofah (~> 2.0) + rails-settings-cached (0.6.5) + rails (>= 4.2.0) rails_12factor (0.0.3) rails_serve_static_assets rails_stdout_logging - rails_autolink (1.1.6) - rails (> 3.1) rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) railties (5.0.1) @@ -332,6 +336,7 @@ GEM rainbow (>= 1.99.1, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) + ruby-oembed (0.10.1) ruby-progressbar (1.8.1) safe_yaml (1.0.4) sass (3.4.22) @@ -367,6 +372,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) + statsd-instrument (2.1.2) temple (0.7.7) term-ansicolor (1.4.0) tins (~> 1.0) @@ -405,6 +411,7 @@ DEPENDENCIES addressable autoprefixer-rails aws-sdk (>= 2.0) + best_in_place (~> 3.0.1) better_errors binding_of_caller browserify-rails @@ -426,14 +433,14 @@ DEPENDENCIES i18n-tasks (~> 0.9.6) jbuilder (~> 2.0) jquery-rails - ledermann-rails-settings letter_opener + letter_opener_web link_header lograge nokogiri oj ostatus2 - paperclip (~> 5.0) + paperclip (~> 5.1) paperclip-av-transcoder pg pg_search @@ -445,23 +452,28 @@ DEPENDENCIES rack-cors rack-timeout-puma rails (~> 5.0.1.0) + rails-settings-cached rails_12factor - rails_autolink react-rails redis (~> 3.2) redis-rails rspec-rails rspec-sidekiq rubocop + ruby-oembed sass-rails (~> 5.0) sdoc (~> 0.4.0) sidekiq simple-navigation simple_form simplecov + statsd-instrument uglifier (>= 1.3.0) webmock will_paginate +RUBY VERSION + ruby 2.3.1p112 + BUNDLED WITH - 1.13.6 + 1.13.7 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000000000000000000000000000000000000..6cdd89518f2dc1a3ccccf676a036b01ff949dc24 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: bundle exec puma -C config/puma.rb +worker: bundle exec sidekiq -q default -q mailers -q push diff --git a/README.md b/README.md index 2d84062a759444cbe017a59a693cc977ef6af4aa..7d3f5a975add1c27090178e1ad0c00a2ae5faa3f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ Mastodon ======== -[][travis] -[][code_climate] +[][travis] +[][code_climate] -[travis]: https://travis-ci.org/Gargron/mastodon -[code_climate]: https://codeclimate.com/github/Gargron/mastodon +[travis]: https://travis-ci.org/tootsuite/mastodon +[code_climate]: https://codeclimate.com/github/tootsuite/mastodon Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. @@ -25,11 +25,11 @@ If you would like, you can [support the development of this project on Patreon][ ## Resources -- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances) +- [List of Mastodon instances](docs/Using-Mastodon/List-of-Mastodon-instances.md) - [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com) -- [API overview](https://github.com/Gargron/mastodon/wiki/API) -- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL) -- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ) +- [API overview](docs/Using-the-API/API.md) +- [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md) +- [List of apps](docs/Using-Mastodon/Apps.md) ## Features @@ -115,7 +115,19 @@ Which will re-create the updated containers, leaving databases and data as is. D ## Deployment without Docker -Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/Gargron/mastodon/wiki/Production-guide) for examples, configuration and instructions. +Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions. + +## Deployment on Heroku (experimental) + +[](https://heroku.com/deploy) + +Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku.md) + +## Development with Vagrant + +A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed. + +[You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant.md) ## Contributing diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000000000000000000000000000000000000..154d0e895b0a43731678e6ea366f62176e5e4c61 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,109 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +$provision = <<SCRIPT + +cd /vagrant # This is where the host folder/repo is mounted + +# Add the yarn repo + yarn repo keys +curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - +sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' + +# Add repo for NodeJS +curl -sL https://deb.nodesource.com/setup_4.x | sudo bash - + +# Add firewall rule to redirect 80 to 3000 and save +sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000 +echo iptables-persistent iptables-persistent/autosave_v4 boolean true | sudo debconf-set-selections +echo iptables-persistent iptables-persistent/autosave_v6 boolean true | sudo debconf-set-selections +sudo apt-get install iptables-persistent -y + +# Add packages to build and run Mastodon +sudo apt-get install \ + git-core \ + g++ \ + libpq-dev \ + libxml2-dev \ + libxslt1-dev \ + imagemagick \ + nodejs \ + redis-server \ + redis-tools \ + postgresql \ + postgresql-contrib \ + yarn \ + libreadline-dev \ + -y + +# Install rbenv +git clone https://github.com/rbenv/rbenv.git ~/.rbenv +cd ~/.rbenv && src/configure && make -C src +echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile +echo 'eval "$(rbenv init -)"' >> ~/.bash_profile + +git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build + +export PATH="$HOME/.rbenv/bin::$PATH" +eval "$(rbenv init -)" + +echo "Compiling Ruby 2.3.1: warning, this takes a while!!!" +rbenv install 2.3.1 +rbenv global 2.3.1 + +cd /vagrant + +# Configure database +sudo -u postgres createuser -U postgres vagrant -s +sudo -u postgres createdb -U postgres mastodon_development + +# Install gems and node modules +gem install bundler +bundle install +yarn install + +# Build Mastodon +bundle exec rails db:setup +bundle exec rails assets:precompile + +SCRIPT + +$start = <<SCRIPT + +cd /vagrant +export $(cat ".env.vagrant" | xargs) +rails s -d -b 0.0.0.0 + +SCRIPT + +VAGRANTFILE_API_VERSION = "2" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + + config.vm.box = "ubuntu/trusty64" + + config.vm.provider :virtualbox do |vb| + vb.name = "mastodon" + vb.customize ["modifyvm", :id, "--memory", "1024"] + end + + config.vm.hostname = "mastodon.dev" + + # This uses the vagrant-hostsupdater plugin, and lets you + # access the development site at http://mastodon.dev. + # To install: + # $ vagrant plugin install hostsupdater + if defined?(VagrantPlugins::HostsUpdater) + config.vm.network :private_network, ip: "192.168.42.42" + config.hostsupdater.remove_on_suspend = false + end + + # Otherwise, you can access the site at http://localhost:3000 + config.vm.network :forwarded_port, guest: 80, host: 3000 + + # Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision' + config.vm.provision :shell, inline: $provision, privileged: false + + # Start up script, runs on every 'vagrant up' + config.vm.provision :shell, inline: $start, run: 'always', privileged: false + +end diff --git a/app.json b/app.json new file mode 100644 index 0000000000000000000000000000000000000000..c0579d33e83d441f2c1d1aa14b2c8682e51f65b3 --- /dev/null +++ b/app.json @@ -0,0 +1,91 @@ +{ + "name": "Mastodon", + "description": "A GNU Social-compatible microblogging server", + "repository": "https://github.com/tootsuite/mastodon", + "logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png", + "env": { + "HEROKU": { + "description": "Leave this as true", + "value": "true", + "required": true + }, + "LOCAL_DOMAIN": { + "description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)", + "required": true + }, + "LOCAL_HTTPS": { + "description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)", + "value": "false", + "required": true + }, + "PAPERCLIP_SECRET": { + "description": "The secret key for storing media files", + "generator": "secret" + }, + "SECRET_KEY_BASE": { + "description": "The secret key base", + "generator": "secret" + }, + "SINGLE_USER_MODE": { + "description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)", + "value": "false", + "required": true + }, + "S3_ENABLED": { + "description": "Should Mastodon use Amazon S3 for storage? This is highly recommended, as Heroku does not have persistent file storage (files will be lost).", + "value": "true", + "required": false + }, + "S3_BUCKET": { + "description": "Amazon S3 Bucket", + "required": false + }, + "S3_REGION": { + "description": "Amazon S3 region that the bucket is located in", + "required": false + }, + "AWS_ACCESS_KEY_ID": { + "description": "Amazon S3 Access Key", + "required": false + }, + "AWS_SECRET_ACCESS_KEY": { + "description": "Amazon S3 Secret Key", + "required": false + }, + "SMTP_SERVER": { + "description": "Hostname for SMTP server, if you want to enable email", + "required": false + }, + "SMTP_PORT": { + "description": "Port for SMTP server", + "required": false + }, + "SMTP_LOGIN": { + "description": "Username for SMTP server", + "required": false + }, + "SMTP_PASSWORD": { + "description": "Password for SMTP server", + "required": false + }, + "SMTP_DOMAIN": { + "description": "Domain for SMTP server. Will default to instance domain if blank.", + "required": false + } + }, + "buildpacks": [ + { + "url": "heroku/nodejs" + }, + { + "url": "heroku/ruby" + } + ], + "scripts": { + "postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed" + }, + "addons": [ + "heroku-postgresql", + "heroku-redis" + ] +} \ No newline at end of file diff --git a/app/assets/images/background-photo.jpeg b/app/assets/images/background-photo.jpeg index 4390fca6670e1cd8781b5bd75aa2bdfe049d55ec..b0a88ff353d89090e4c2bd01ae4b6632ee5d39f7 100644 Binary files a/app/assets/images/background-photo.jpeg and b/app/assets/images/background-photo.jpeg differ diff --git a/app/assets/images/boost_sprite.png b/app/assets/images/boost_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..564bf26463b0e458019647bc80892363a2a7ed27 Binary files /dev/null and b/app/assets/images/boost_sprite.png differ diff --git a/app/assets/javascripts/application_public.js b/app/assets/javascripts/application_public.js index f131a267ab71ef3c5dd5494fa18d6b30c38a296b..9626c5dae1ad3b42b3c1f6a072fc0578d499242c 100644 --- a/app/assets/javascripts/application_public.js +++ b/app/assets/javascripts/application_public.js @@ -1,3 +1,8 @@ //= require jquery //= require jquery_ujs //= require extras +//= require best_in_place + +$(function () { + $(".best_in_place").best_in_place(); +}); diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 8d28b051f3a0a267bbc04f3dd30c54d37439d986..0be05034e91968f4115db06e6828af926047e6e3 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -1,8 +1,6 @@ import api, { getLinks } from '../api' import Immutable from 'immutable'; -export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; - export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; @@ -67,13 +65,6 @@ 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 function setAccountSelf(account) { - return { - type: ACCOUNT_SET_SELF, - account - }; -}; - export function fetchAccount(id) { return (dispatch, getState) => { dispatch(fetchAccountRequest(id)); @@ -89,32 +80,39 @@ export function fetchAccount(id) { export function fetchAccountTimeline(id, replace = false) { return (dispatch, getState) => { - dispatch(fetchAccountTimelineRequest(id)); - - const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()); + const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; let params = ''; + let skipLoading = false; if (newestId !== null && !replace) { - params = `?since_id=${newestId}`; + params = `?since_id=${newestId}`; + skipLoading = true; } + dispatch(fetchAccountTimelineRequest(id, skipLoading)); + api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => { - dispatch(fetchAccountTimelineSuccess(id, response.data, replace)); + dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading)); }).catch(error => { - dispatch(fetchAccountTimelineFail(id, error)); + dispatch(fetchAccountTimelineFail(id, error, skipLoading)); }); }; }; export function expandAccountTimeline(id) { return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last(); + const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last(); dispatch(expandAccountTimelineRequest(id)); - api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => { + api(getState).get(`/api/v1/accounts/${id}/statuses`, { + params: { + limit: 10, + max_id: lastId + } + }).then(response => { dispatch(expandAccountTimelineSuccess(id, response.data)); }).catch(error => { dispatch(expandAccountTimelineFail(id, error)); @@ -210,27 +208,30 @@ export function unfollowAccountFail(error) { }; }; -export function fetchAccountTimelineRequest(id) { +export function fetchAccountTimelineRequest(id, skipLoading) { return { type: ACCOUNT_TIMELINE_FETCH_REQUEST, - id + id, + skipLoading }; }; -export function fetchAccountTimelineSuccess(id, statuses, replace) { +export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) { return { type: ACCOUNT_TIMELINE_FETCH_SUCCESS, id, statuses, - replace + replace, + skipLoading }; }; -export function fetchAccountTimelineFail(id, error) { +export function fetchAccountTimelineFail(id, error, skipLoading) { return { type: ACCOUNT_TIMELINE_FETCH_FAIL, id, - error + error, + skipLoading }; }; @@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) { export function fetchRelationships(account_ids) { return (dispatch, getState) => { + if (account_ids.length === 0) { + return; + } + dispatch(fetchRelationshipsRequest(account_ids)); api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => { @@ -508,21 +513,24 @@ export function fetchRelationships(account_ids) { export function fetchRelationshipsRequest(ids) { return { type: RELATIONSHIPS_FETCH_REQUEST, - ids + ids, + skipLoading: true }; }; export function fetchRelationshipsSuccess(relationships) { return { type: RELATIONSHIPS_FETCH_SUCCESS, - relationships + relationships, + skipLoading: true }; }; export function fetchRelationshipsFail(error) { return { type: RELATIONSHIPS_FETCH_FAIL, - error + error, + skipLoading: true }; }; diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx new file mode 100644 index 0000000000000000000000000000000000000000..503c2bfeb6dbceac0dabd8a7535d34bc3c61f6a7 --- /dev/null +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -0,0 +1,47 @@ +import api from '../api'; + +export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; +export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; +export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; + +export function fetchStatusCard(id) { + return (dispatch, getState) => { + dispatch(fetchStatusCardRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { + if (!response.data.url || !response.data.title || !response.data.description) { + return; + } + + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchStatusCardFail(id, error)); + }); + }; +}; + +export function fetchStatusCardRequest(id) { + return { + type: STATUS_CARD_FETCH_REQUEST, + id, + skipLoading: true + }; +}; + +export function fetchStatusCardSuccess(id, card) { + return { + type: STATUS_CARD_FETCH_SUCCESS, + id, + card, + skipLoading: true + }; +}; + +export function fetchStatusCardFail(id, error) { + return { + type: STATUS_CARD_FETCH_FAIL, + id, + error, + skipLoading: true + }; +}; diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 05674ba8988feafb151a395d73a1ca1d48363565..6d0188166e265488f11a31b1cdde2b4660b4e337 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -23,6 +23,8 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; 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'; @@ -68,6 +70,7 @@ export function submitCompose() { in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']), + spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public') }).then(function (response) { dispatch(submitComposeSuccess({ ...response.data })); @@ -218,6 +221,20 @@ export function changeComposeSensitivity(checked) { }; }; +export function changeComposeSpoilerness(checked) { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + checked + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text + }; +}; + export function changeComposeVisibility(checked) { return { type: COMPOSE_VISIBILITY_CHANGE, diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a25c1ae1c861e5d386eeb1683289ab7260dab1bb --- /dev/null +++ b/app/assets/javascripts/components/actions/favourites.jsx @@ -0,0 +1,83 @@ +import api, { getLinks } from '../api' + +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) => { + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; +}; + +export function fetchFavouritedStatusesRequest() { + return { + type: FAVOURITED_STATUSES_FETCH_REQUEST + }; +}; + +export function fetchFavouritedStatusesSuccess(statuses, next) { + return { + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next + }; +}; + +export function fetchFavouritedStatusesFail(error) { + return { + type: FAVOURITED_STATUSES_FETCH_FAIL, + error + }; +}; + +export function expandFavouritedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'favourites', 'next'], null); + + if (url === null) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + 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/assets/javascripts/components/actions/meta.jsx b/app/assets/javascripts/components/actions/meta.jsx deleted file mode 100644 index d0adbce3f9e3ff1bc76876a35b3227327f3493ac..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/components/actions/meta.jsx +++ /dev/null @@ -1,8 +0,0 @@ -export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET'; - -export function setAccessToken(token) { - return { - type: ACCESS_TOKEN_SET, - token: token - }; -}; diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index 8bd83540631a4c944edf04ccbb09e325f5905ec1..1731c1857ae5e20c68ae8a77b2d0d0d42ec95a24 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -14,8 +14,6 @@ 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_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE'; - const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); @@ -26,21 +24,25 @@ const fetchRelatedRelationships = (dispatch, notifications) => { export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + dispatch({ type: NOTIFICATIONS_UPDATE, notification, account: notification.account, - status: notification.status + status: notification.status, + meta: playSound ? { sound: 'boop' } : undefined }); fetchRelatedRelationships(dispatch, [notification]); // Desktop notifications - if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) { + if (typeof window.Notification !== 'undefined' && showAlert) { 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 = $('<p>').html(notification.status ? notification.status.content : '').text(); - new Notification(title, { body, icon: notification.account.avatar }); + new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); } }; }; @@ -94,13 +96,17 @@ export function expandNotifications() { return (dispatch, getState) => { const url = getState().getIn(['notifications', 'next'], null); - if (url === null) { + if (url === null || getState().getIn(['notifications', 'isLoading'])) { return; } dispatch(expandNotificationsRequest()); - api(getState).get(url).then(response => { + api(getState).get(url, { + params: { + limit: 5 + } + }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); @@ -133,11 +139,3 @@ export function expandNotificationsFail(error) { error }; }; - -export function changeNotificationsSetting(key, checked) { - return { - type: NOTIFICATIONS_SETTING_CHANGE, - key, - checked - }; -}; diff --git a/app/assets/javascripts/components/actions/settings.jsx b/app/assets/javascripts/components/actions/settings.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c754b30ca2fc4d1656e61c5f55dafc957e9ce9cf --- /dev/null +++ b/app/assets/javascripts/components/actions/settings.jsx @@ -0,0 +1,19 @@ +import axios from 'axios'; + +export const SETTING_CHANGE = 'SETTING_CHANGE'; + +export function changeSetting(key, value) { + return { + type: SETTING_CHANGE, + key, + value + }; +}; + +export function saveSettings() { + return (_, getState) => { + axios.put('/api/web/settings', { + data: getState().get('settings').toJS() + }); + }; +}; diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index cbee94bca37bcd38bfc8ba24a981cceb7f9a4deb..9ac215727a8c46658ba52b78d68f32beb3da5b42 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -1,6 +1,7 @@ import api from '../api'; import { deleteFromTimelines } from './timelines'; +import { fetchStatusCard } from './cards'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -14,39 +15,44 @@ 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 function fetchStatusRequest(id) { +export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, - id: id + id, + skipLoading }; }; export function fetchStatus(id) { return (dispatch, getState) => { - dispatch(fetchStatusRequest(id)); + const skipLoading = getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchStatusRequest(id, skipLoading)); api(getState).get(`/api/v1/statuses/${id}`).then(response => { - dispatch(fetchStatusSuccess(response.data)); + dispatch(fetchStatusSuccess(response.data, skipLoading)); dispatch(fetchContext(id)); + dispatch(fetchStatusCard(id)); }).catch(error => { - dispatch(fetchStatusFail(id, error)); + dispatch(fetchStatusFail(id, error, skipLoading)); }); }; }; -export function fetchStatusSuccess(status, context) { +export function fetchStatusSuccess(status, skipLoading) { return { type: STATUS_FETCH_SUCCESS, - status: status, - context: context + status, + skipLoading }; }; -export function fetchStatusFail(id, error) { +export function fetchStatusFail(id, error, skipLoading) { return { type: STATUS_FETCH_FAIL, - id: id, - error: error + id, + error, + skipLoading }; }; diff --git a/app/assets/javascripts/components/actions/store.jsx b/app/assets/javascripts/components/actions/store.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3bba99549cecb70211b4a1016144052700067a17 --- /dev/null +++ b/app/assets/javascripts/components/actions/store.jsx @@ -0,0 +1,17 @@ +import Immutable from 'immutable'; + +export const STORE_HYDRATE = 'STORE_HYDRATE'; + +const convertState = rawState => + Immutable.fromJS(rawState, (k, v) => + Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => + Number.isNaN(x * 1) ? x : x * 1)); + +export function hydrateStore(rawState) { + const state = convertState(rawState); + + return { + type: STORE_HYDRATE, + state + }; +}; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 0e6f09190d24e904cc5f629ae4c75784a8fbc3ff..29a060e87ab1de6a5bfeaabeca6b1dc8e8f8e5f2 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; -export function refreshTimelineSuccess(timeline, statuses) { +export function refreshTimelineSuccess(timeline, statuses, skipLoading) { return { type: TIMELINE_REFRESH_SUCCESS, - timeline: timeline, - statuses: statuses + timeline, + statuses, + skipLoading }; }; @@ -39,55 +40,65 @@ export function deleteFromTimelines(id) { return (dispatch, getState) => { const accountId = getState().getIn(['statuses', id, 'account']); const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); dispatch({ type: TIMELINE_DELETE, id, accountId, - references + references, + reblogOf }); }; }; -export function refreshTimelineRequest(timeline, id) { +export function refreshTimelineRequest(timeline, id, skipLoading) { return { type: TIMELINE_REFRESH_REQUEST, timeline, - id + id, + skipLoading }; }; export function refreshTimeline(timeline, id = null) { return function (dispatch, getState) { - dispatch(refreshTimelineRequest(timeline, id)); + if (getState().getIn(['timelines', timeline, 'isLoading'])) { + return; + } const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; - let params = ''; - let path = timeline; + let params = ''; + let path = timeline; + let skipLoading = false; if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) { - params = `?since_id=${newestId}`; + params = `?since_id=${newestId}`; + skipLoading = true; } if (id) { path = `${path}/${id}` } + dispatch(refreshTimelineRequest(timeline, id, skipLoading)); + api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data)); + dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading)); }).catch(function (error) { - dispatch(refreshTimelineFail(timeline, error)); + dispatch(refreshTimelineFail(timeline, error, skipLoading)); }); }; }; -export function refreshTimelineFail(timeline, error) { +export function refreshTimelineFail(timeline, error, skipLoading) { return { type: TIMELINE_REFRESH_FAIL, timeline, - error + error, + skipLoading }; }; @@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) { return (dispatch, getState) => { const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); + if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) { + // If timeline is empty, don't try to load older posts since there are none + // Also if already loading + return; + } + dispatch(expandTimelineRequest(timeline)); let path = timeline; @@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) { path = `${path}/${id}` } - api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => { + api(getState).get(`/api/v1/timelines/${path}`, { + params: { + limit: 10, + max_id: lastId + } + }).then(response => { dispatch(expandTimelineSuccess(timeline, response.data)); }).catch(error => { dispatch(expandTimelineFail(timeline, error)); diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx index 814d8a9c8e29f732bf494aba52081e831ec3088e..108401b2fd193b3f04d5c6acae3e0774ccf11e76 100644 --- a/app/assets/javascripts/components/components/account.jsx +++ b/app/assets/javascripts/components/components/account.jsx @@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' } + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock' } }); const outerStyle = { @@ -42,7 +44,9 @@ const Account = React.createClass({ account: ImmutablePropTypes.map.isRequired, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, - withNote: React.PropTypes.bool + onBlock: React.PropTypes.func.isRequired, + withNote: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired }, getDefaultProps () { @@ -57,6 +61,10 @@ const Account = React.createClass({ this.props.onFollow(this.props.account); }, + handleBlock () { + this.props.onBlock(this.props.account); + }, + render () { const { account, me, withNote, intl } = this.props; @@ -70,10 +78,18 @@ const Account = React.createClass({ note = <div style={noteStyle}>{account.get('note')}</div>; } - if (account.get('id') !== me && account.get('relationship', null) != null) { + if (account.get('id') !== me && account.get('relationship', null) !== null) { const following = account.getIn(['relationship', 'following']); - - buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + + if (requested) { + buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> + } else if (blocking) { + buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />; + } else { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } } return ( diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx index 39ccbcaf96763f16dbf477a88ffd8c633758d430..81ec7a236dbc9bc016e5359e69724f1335118cce 100644 --- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({ onSuggestionsClearRequested: React.PropTypes.func.isRequired, onSuggestionsFetchRequested: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired, - onKeyUp: React.PropTypes.func + onKeyUp: React.PropTypes.func, + onKeyDown: React.PropTypes.func }, getInitialState () { @@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({ break; } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); }, onBlur () { - this.setState({ suggestionsHidden: true }); + // If we hide the suggestions immediately, then this will prevent the + // onClick for the suggestions themselves from firing. + // Setting a short window for that to take place before hiding the + // suggestions ensures that can't happen. + setTimeout(() => { + this.setState({ suggestionsHidden: true }); + }, 100); }, onSuggestionClick (suggestion, e) { e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); }, componentWillReceiveProps (nextProps) { diff --git a/app/assets/javascripts/components/components/avatar.jsx b/app/assets/javascripts/components/components/avatar.jsx index 687aa7bb91c23176f4378fb30231533d6cd11e5b..b8420014bd641665350ed06ca6a2bc8fde28f5ae 100644 --- a/app/assets/javascripts/components/components/avatar.jsx +++ b/app/assets/javascripts/components/components/avatar.jsx @@ -8,12 +8,41 @@ const Avatar = React.createClass({ style: React.PropTypes.object }, + getInitialState () { + return { + hovering: false + }; + }, + mixins: [PureRenderMixin], + handleMouseEnter () { + this.setState({ hovering: true }); + }, + + handleMouseLeave () { + this.setState({ hovering: false }); + }, + + handleLoad () { + this.canvas.getContext('2d').drawImage(this.image, 0, 0, this.props.size, this.props.size); + }, + + setImageRef (c) { + this.image = c; + }, + + setCanvasRef (c) { + this.canvas = c; + }, + render () { + const { hovering } = this.state; + return ( - <div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}> - <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} /> + <div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}> + <img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', visibility: hovering ? 'visible' : 'hidden', borderRadius: '4px' }} /> + <canvas ref={this.setCanvasRef} width={this.props.size} height={this.props.size} style={{ borderRadius: '4px' }} /> </div> ); } diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx index d631290131f4ba4ff1da95e79e6d2a1c6fbeaa79..19c52550a8230737ddfcf30d2c6af400f7869fa2 100644 --- a/app/assets/javascripts/components/components/button.jsx +++ b/app/assets/javascripts/components/components/button.jsx @@ -27,7 +27,7 @@ const Button = React.createClass({ render () { const style = { - fontFamily: 'Roboto', + fontFamily: 'inherit', display: this.props.block ? 'block' : 'inline-block', width: this.props.block ? '100%' : 'auto', position: 'relative', diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..203dc5e0c8f94d3c79ea4a6672c8e02336600f4f --- /dev/null +++ b/app/assets/javascripts/components/components/column_collapsable.jsx @@ -0,0 +1,60 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; + +const iconStyle = { + fontSize: '16px', + padding: '15px', + position: 'absolute', + right: '0', + top: '-48px', + cursor: 'pointer' +}; + +const ColumnCollapsable = React.createClass({ + + propTypes: { + icon: React.PropTypes.string.isRequired, + fullHeight: React.PropTypes.number.isRequired, + children: React.PropTypes.node, + onCollapse: React.PropTypes.func + }, + + getInitialState () { + return { + collapsed: true + }; + }, + + mixins: [PureRenderMixin], + + handleToggleCollapsed () { + const currentState = this.state.collapsed; + + this.setState({ collapsed: !currentState }); + + if (!currentState && this.props.onCollapse) { + this.props.onCollapse(); + } + }, + + render () { + const { icon, fullHeight, children } = this.props; + const { collapsed } = this.state; + + return ( + <div style={{ position: 'relative' }}> + <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div> + + <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}> + {({ opacity, height }) => + <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}> + {children} + </div> + } + </Motion> + </div> + ); + } +}); + +export default ColumnCollapsable; diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx index 450550d55acdd37f8d4d8d3987fb4c88ffd1c678..ffef29c0012dbd29a3ed95c751c76e3a79b105e3 100644 --- a/app/assets/javascripts/components/components/dropdown_menu.jsx +++ b/app/assets/javascripts/components/components/dropdown_menu.jsx @@ -1,13 +1,15 @@ import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; -const DropdownMenu = ({ icon, items, size }) => { +const DropdownMenu = ({ icon, items, size, direction }) => { + const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right"; + return ( <Dropdown> <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}> <i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} /> </DropdownTrigger> - <DropdownContent style={{ lineHeight: '18px', textAlign: 'left' }}> + <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}> <ul> {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => { if (typeof action === 'function') { diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx index e9a7228e4d715b86b7a69467f405c518d9f3ab99..f9b6192c04ad43f8a00a5703dc7bf96d401c3e99 100644 --- a/app/assets/javascripts/components/components/icon_button.jsx +++ b/app/assets/javascripts/components/components/icon_button.jsx @@ -1,4 +1,5 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; const IconButton = React.createClass({ @@ -10,14 +11,16 @@ const IconButton = React.createClass({ active: React.PropTypes.bool, style: React.PropTypes.object, activeStyle: React.PropTypes.object, - disabled: React.PropTypes.bool + disabled: React.PropTypes.bool, + animate: React.PropTypes.bool }, getDefaultProps () { return { size: 18, active: false, - disabled: false + disabled: false, + animate: false }; }, @@ -49,9 +52,18 @@ const IconButton = React.createClass({ } return ( - <button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}> - <i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> - </button> + <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> + {({ rotate }) => + <button + aria-label={this.props.title} + title={this.props.title} + className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} + onClick={this.handleClick} + style={style}> + <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> + </button> + } + </Motion> ); } diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx index b5c2a69d83705989c41da25fa9f6673dec8c6358..1e3a889551ca862638348f5b8bbe08be635ede71 100644 --- a/app/assets/javascripts/components/components/lightbox.jsx +++ b/app/assets/javascripts/components/components/lightbox.jsx @@ -35,7 +35,9 @@ const Lightbox = React.createClass({ propTypes: { isVisible: React.PropTypes.bool, onOverlayClicked: React.PropTypes.func, - onCloseClicked: React.PropTypes.func + onCloseClicked: React.PropTypes.func, + intl: React.PropTypes.object.isRequired, + children: React.PropTypes.node }, mixins: [PureRenderMixin], @@ -57,19 +59,17 @@ const Lightbox = React.createClass({ render () { const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props; - const content = isVisible ? children : <div />; - return ( - <div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}> - <Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}> - {({ y }) => - <div style={{...dialogStyle, transform: `translateY(${y}px)`}}> + <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}> + {({ backgroundOpacity, opacity, y }) => + <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}> + <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}> <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} /> - {content} + {children} </div> - } - </Motion> - </div> + </div> + } + </Motion> ); } diff --git a/app/assets/javascripts/components/components/loading_indicator.jsx b/app/assets/javascripts/components/components/loading_indicator.jsx index fd5acae84e5b2273ba43918b78da260a45fafd30..c8a2639241f61ecde33b4306784b7f84c259850d 100644 --- a/app/assets/javascripts/components/components/loading_indicator.jsx +++ b/app/assets/javascripts/components/components/loading_indicator.jsx @@ -1,15 +1,17 @@ import { FormattedMessage } from 'react-intl'; -const LoadingIndicator = () => { - const style = { - textAlign: 'center', - fontSize: '16px', - fontWeight: '500', - color: '#616b86', - paddingTop: '120px' - }; - - return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>; +const style = { + textAlign: 'center', + fontSize: '16px', + fontWeight: '500', + color: '#616b86', + paddingTop: '120px' }; +const LoadingIndicator = () => ( + <div style={style}> + <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> + </div> +); + export default LoadingIndicator; diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index 9aafd81816568f6efa56b331b5bf423fdf7936bb..7e92abe2d205e4c0ce5f461d6ad863a88bcde14f 100644 --- a/app/assets/javascripts/components/components/media_gallery.jsx +++ b/app/assets/javascripts/components/components/media_gallery.jsx @@ -1,12 +1,18 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import { FormattedMessage } from 'react-intl'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' } +}); const outerStyle = { marginTop: '8px', overflow: 'hidden', width: '100%', - boxSizing: 'border-box' + boxSizing: 'border-box', + position: 'relative' }; const spoilerStyle = { @@ -32,11 +38,18 @@ const spoilerSubSpanStyle = { fontWeight: '500' }; +const spoilerButtonStyle = { + position: 'absolute', + top: '6px', + left: '8px', + zIndex: '100' +}; + const MediaGallery = React.createClass({ getInitialState () { return { - visible: false + visible: !this.props.sensitive }; }, @@ -59,21 +72,30 @@ const MediaGallery = React.createClass({ }, handleOpen () { - this.setState({ visible: true }); + this.setState({ visible: !this.state.visible }); }, render () { - const { media, sensitive } = this.props; + const { media, intl, sensitive } = this.props; let children; - if (sensitive && !this.state.visible) { - children = ( - <div style={spoilerStyle} onClick={this.handleOpen}> - <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> - <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </div> - ); + if (!this.state.visible) { + if (sensitive) { + children = ( + <div style={spoilerStyle} onClick={this.handleOpen}> + <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + children = ( + <div style={spoilerStyle} onClick={this.handleOpen}> + <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } } else { const size = media.take(4).size; @@ -134,9 +156,12 @@ const MediaGallery = React.createClass({ ); }); } - + return ( <div style={{ ...outerStyle, height: `${this.props.height}px` }}> + <div style={spoilerButtonStyle} > + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} /> + </div> {children} </div> ); @@ -144,4 +169,4 @@ const MediaGallery = React.createClass({ }); -export default MediaGallery; +export default injectIntl(MediaGallery); diff --git a/app/assets/javascripts/components/components/missing_indicator.jsx b/app/assets/javascripts/components/components/missing_indicator.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ed8b4fe24a3edfc198200624de62ab087ee31ddf --- /dev/null +++ b/app/assets/javascripts/components/components/missing_indicator.jsx @@ -0,0 +1,17 @@ +import { FormattedMessage } from 'react-intl'; + +const style = { + textAlign: 'center', + fontSize: '16px', + fontWeight: '500', + color: '#616b86', + paddingTop: '120px' +}; + +const MissingIndicator = () => ( + <div style={style}> + <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' /> + </div> +); + +export default MissingIndicator; diff --git a/app/assets/javascripts/components/components/relative_timestamp.jsx b/app/assets/javascripts/components/components/relative_timestamp.jsx index 3a5b88523617cc066aaa5f2a2de71cf89fd1f690..3b012b184b4344ca67198d72005f5c35cfe873cb 100644 --- a/app/assets/javascripts/components/components/relative_timestamp.jsx +++ b/app/assets/javascripts/components/components/relative_timestamp.jsx @@ -1,15 +1,18 @@ -import { - FormattedMessage, - FormattedDate, - FormattedRelative -} from 'react-intl'; - -const RelativeTimestamp = ({ timestamp }) => { - return <FormattedRelative value={new Date(timestamp)} />; +import { injectIntl, FormattedRelative } from 'react-intl'; + +const RelativeTimestamp = ({ intl, timestamp }) => { + const date = new Date(timestamp); + + return ( + <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}> + <FormattedRelative value={date} /> + </time> + ); }; RelativeTimestamp.propTypes = { + intl: React.PropTypes.object.isRequired, timestamp: React.PropTypes.string.isRequired }; -export default RelativeTimestamp; +export default injectIntl(RelativeTimestamp); diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index afaf825619e4836ffb333be355c19b02f8c56595..f2cc1fb1200719ef899b6da68fb69a4d2037d3fb 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({ }, handleMentionClick () { - this.props.onMention(this.props.status.get('account')); + this.props.onMention(this.props.status.get('account'), this.context.router); }, handleBlockClick () { @@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({ <div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div> - <div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + <div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ width: '18px', height: '18px', float: 'left' }}> - <DropdownMenu items={menu} icon='ellipsis-h' size={18} /> + <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" /> </div> </div> ); diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index f2c88cee0fe256bac736069e0f992b0d9ea81642..521b557f0e707d4ba60bc30787dec8e46359975d 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -1,6 +1,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import emojify from '../emoji'; +import { FormattedMessage } from 'react-intl'; const StatusContent = React.createClass({ @@ -13,6 +14,12 @@ const StatusContent = React.createClass({ onClick: React.PropTypes.func }, + getInitialState () { + return { + hidden: true + }; + }, + mixins: [PureRenderMixin], componentDidMount () { @@ -31,8 +38,6 @@ const StatusContent = React.createClass({ link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); } - - link.addEventListener('click', this.onNormalClick, false); } }, @@ -52,16 +57,59 @@ const StatusContent = React.createClass({ } }, - onNormalClick (e) { - e.stopPropagation(); + handleMouseDown (e) { + this.startXY = [e.clientX, e.clientY]; + }, + + handleMouseUp (e) { + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0) { + this.props.onClick(); + } + + this.startXY = null; + }, + + handleSpoilerClick () { + this.setState({ hidden: !this.state.hidden }); }, render () { - const { status, onClick } = this.props; + const { status } = this.props; + const { hidden } = this.state; const content = { __html: emojify(status.get('content')) }; - - return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />; + const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) }; + + if (status.get('spoiler_text').length > 0) { + const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; + + return ( + <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <p style={{ marginBottom: hidden ? '0px' : '' }} > + <span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a> + </p> + + <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> + </div> + ); + } else { + return ( + <div + className='status__content' + style={{ cursor: 'pointer' }} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + ); + } }, }); diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx index e0a73435feb3d0bd3f4d20bdfb68014aa92cb70f..69cc013f2f69f3c0e085c10229a5f8d0283e6286 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -11,7 +11,8 @@ const StatusList = React.createClass({ onScrollToBottom: React.PropTypes.func, onScrollToTop: React.PropTypes.func, onScroll: React.PropTypes.func, - trackScroll: React.PropTypes.bool + trackScroll: React.PropTypes.bool, + isLoading: React.PropTypes.bool }, getDefaultProps () { @@ -24,10 +25,10 @@ const StatusList = React.createClass({ handleScroll (e) { const { scrollTop, scrollHeight, clientHeight } = e.target; - + const offset = scrollHeight - scrollTop - clientHeight; this._oldScrollPosition = scrollHeight - scrollTop; - if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) { + if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) { this.props.onScrollToBottom(); } else if (scrollTop < 100 && this.props.onScrollToTop) { this.props.onScrollToTop(); @@ -36,21 +37,37 @@ const StatusList = React.createClass({ } }, - componentDidUpdate (prevProps) { - if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) { - const node = ReactDOM.findDOMNode(this); + componentDidMount () { + this.attachScrollListener(); + }, - if (node.scrollTop > 0) { - node.scrollTop = node.scrollHeight - this._oldScrollPosition; - } + componentDidUpdate (prevProps) { + if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) { + this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; } }, + componentWillUnmount () { + this.detachScrollListener(); + }, + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + }, + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + }, + + setRef (c) { + this.node = c; + }, + render () { const { statusIds, onScrollToBottom, trackScroll } = this.props; const scrollableArea = ( - <div className='scrollable' onScroll={this.handleScroll}> + <div className='scrollable' ref={this.setRef}> <div> {statusIds.map((statusId) => { return <StatusContainer key={statusId} id={statusId} />; diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index 8f64ad3cd32c4184c969a281c1fd737facb95798..3edc8f6727bd9aa5d67506d3a261340411eb5e26 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.jsx @@ -4,7 +4,8 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ - toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' } + toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, + toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' } }); const videoStyle = { @@ -20,7 +21,7 @@ const videoStyle = { const muteStyle = { position: 'absolute', top: '10px', - left: '10px', + right: '10px', opacity: '0.8', zIndex: '5' }; @@ -35,7 +36,8 @@ const spoilerStyle = { display: 'flex', alignItems: 'center', justifyContent: 'center', - flexDirection: 'column' + flexDirection: 'column', + position: 'relative' }; const spoilerSpanStyle = { @@ -49,6 +51,13 @@ const spoilerSubSpanStyle = { fontWeight: '500' }; +const spoilerButtonStyle = { + position: 'absolute', + top: '6px', + left: '8px', + zIndex: '100' +}; + const VideoPlayer = React.createClass({ propTypes: { media: ImmutablePropTypes.map.isRequired, @@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({ getInitialState () { return { - visible: false, + visible: !this.props.sensitive, + preview: true, muted: true }; }, @@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({ }, handleOpen () { - this.setState({ visible: true }); + this.setState({ preview: !this.state.preview }); + }, + + handleVisibility () { + this.setState({ + visible: !this.state.visible, + preview: true + }); }, render () { const { media, intl, width, height, sensitive } = this.props; - if (sensitive && !this.state.visible) { - return ( - <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> - <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> - <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </div> - ); - } else if (!sensitive && !this.state.visible) { + let spoilerButton = ( + <div style={spoilerButtonStyle} > + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> + </div> + ); + + if (!this.state.visible) { + if (sensitive) { + return ( + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}> + {spoilerButton} + <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + return ( + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> + {spoilerButton} + <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } + } + + if (this.state.preview) { return ( <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> + {spoilerButton} <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div> </div> ); @@ -113,7 +150,8 @@ const VideoPlayer = React.createClass({ return ( <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> - <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div> + {spoilerButton} + <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div> <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> </div> ); diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx index 1f49f9819a250df894adda13740feb9d1713a133..889c0ac4c043a3d35d816a078597b41a9fda7c9b 100644 --- a/app/assets/javascripts/components/containers/account_container.jsx +++ b/app/assets/javascripts/components/containers/account_container.jsx @@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors'; import Account from '../components/account'; import { followAccount, - unfollowAccount + unfollowAccount, + blockAccount, + unblockAccount } from '../actions/accounts'; const makeMapStateToProps = () => { @@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({ } else { dispatch(followAccount(account.get('id'))); } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 67045537651bba521a1f037c4e35a0292f95c405..5f4b2cf79bdd69f58b0488fa05ce81e798af9098 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -7,15 +7,13 @@ import { refreshTimeline } from '../actions/timelines'; import { updateNotifications } from '../actions/notifications'; -import { setAccessToken } from '../actions/meta'; -import { setAccountSelf } from '../actions/accounts'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; import createBrowserHistory from 'history/lib/createBrowserHistory'; import { applyRouterMiddleware, useRouterHistory, Router, Route, + IndexRedirect, IndexRoute } from 'react-router'; import { useScroll } from 'react-router-scroll'; @@ -35,6 +33,8 @@ import Favourites from '../features/favourites'; import HashtagTimeline from '../features/hashtag_timeline'; import Notifications from '../features/notifications'; import FollowRequests from '../features/follow_requests'; +import GenericNotFound from '../features/generic_not_found'; +import FavouritedStatuses from '../features/favourited_statuses'; import { IntlProvider, addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; @@ -44,9 +44,12 @@ import pt from 'react-intl/locale-data/pt'; import hu from 'react-intl/locale-data/hu'; import uk from 'react-intl/locale-data/uk'; import getMessagesForLocale from '../locales'; +import { hydrateStore } from '../actions/store'; const store = configureStore(); +store.dispatch(hydrateStore(window.INITIAL_STATE)); + const browserHistory = useRouterHistory(createBrowserHistory)({ basename: '/web' }); @@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]); const Mastodon = React.createClass({ propTypes: { - token: React.PropTypes.string.isRequired, - timelines: React.PropTypes.object, - account: React.PropTypes.string, locale: React.PropTypes.string.isRequired }, - mixins: [PureRenderMixin], - componentWillMount() { - const { token, account, locale } = this.props; - - store.dispatch(setAccessToken(token)); - store.dispatch(setAccountSelf(JSON.parse(account))); + const { locale } = this.props; if (typeof App !== 'undefined') { this.subscription = App.cable.subscriptions.create('TimelineChannel', { received (data) { switch(data.type) { - case 'update': - return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); - case 'delete': - return store.dispatch(deleteFromTimelines(data.id)); - case 'notification': - return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); + case 'update': + store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); + break; + case 'delete': + store.dispatch(deleteFromTimelines(data.id)); + break; + case 'notification': + store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); + break; } } @@ -107,14 +105,16 @@ const Mastodon = React.createClass({ <Provider store={store}> <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}> <Route path='/' component={UI}> - <IndexRoute component={GettingStarted} /> + <IndexRedirect to="/getting-started" /> + <Route path='getting-started' component={GettingStarted} /> <Route path='timelines/home' component={HomeTimeline} /> <Route path='timelines/mentions' component={MentionsTimeline} /> <Route path='timelines/public' component={PublicTimeline} /> <Route path='timelines/tag/:id' component={HashtagTimeline} /> <Route path='notifications' component={Notifications} /> + <Route path='favourites' component={FavouritedStatuses} /> <Route path='statuses/new' component={Compose} /> <Route path='statuses/:statusId' component={Status} /> @@ -128,6 +128,7 @@ const Mastodon = React.createClass({ </Route> <Route path='follow_requests' component={FollowRequests} /> + <Route path='*' component={GenericNotFound} /> </Route> </Router> </Provider> diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index 6a882eab4c1f76f155345989a34b6e04322231c9..ad2be03d184ca1050de37d8124c0ccc59291cd5a 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts'; import { deleteStatus } from '../actions/statuses'; import { openMedia } from '../actions/modal'; import { createSelector } from 'reselect' +import { isMobile } from '../is_mobile' const mapStateToProps = (state, props) => ({ statusBase: state.getIn(['statuses', props.id]), @@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(deleteStatus(status.get('id'))); }, - onMention (account) { + onMention (account, router) { dispatch(mentionCompose(account)); + if (isMobile(window.innerWidth)) { + router.push('/statuses/new'); + } }, onOpenMedia (url) { diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx index a06c759531347e419a1d4d811cd3f8d284635c16..c93c07c74b283394c601d592fa059dc801963842 100644 --- a/app/assets/javascripts/components/emoji.jsx +++ b/app/assets/javascripts/components/emoji.jsx @@ -5,5 +5,5 @@ emojione.sprites = false; emojione.imagePathPNG = '/emoji/'; export default function emojify(text) { - return emojione.unicodeToImage(text); + return emojione.toImage(text); }; diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index 45de75d972deb74f807e7f173063af771b94eb7e..ab7b08dc7da6c776747abdb8dbb84aa3e1f6e57d 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -66,7 +66,7 @@ const ActionBar = React.createClass({ return ( <div style={outerStyle}> <div style={outerDropdownStyle}> - <DropdownMenu items={menu} icon='bars' size={24} /> + <DropdownMenu items={menu} icon='bars' size={24} direction="right" /> </div> <div style={outerLinksStyle}> diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index 6ae5ac002bf7dea52c9a6723aa13f5187f4f387a..dead11265b1869418c0f1dbb9511bf927fcca71d 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -71,8 +71,8 @@ const Header = React.createClass({ <span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> </a> - <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> - <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> + <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> + <div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> {info} {actionBtn} diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx index c2cc58bb29b40e25fae8b6a0e2b50be624d3db14..3a9b48f2121ac5c0fcc43a497826221d6f28ee08 100644 --- a/app/assets/javascripts/components/features/account/index.jsx +++ b/app/assets/javascripts/components/features/account/index.jsx @@ -20,6 +20,7 @@ import LoadingIndicator from '../../components/loading_indicator'; import ActionBar from './components/action_bar'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; +import { isMobile } from '../../is_mobile' const makeMapStateToProps = () => { const getAccount = makeGetAccount(); @@ -34,11 +35,16 @@ const makeMapStateToProps = () => { const Account = React.createClass({ + contextTypes: { + router: React.PropTypes.object + }, + propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, account: ImmutablePropTypes.map, - me: React.PropTypes.number.isRequired + me: React.PropTypes.number.isRequired, + children: React.PropTypes.node }, mixins: [PureRenderMixin], @@ -71,6 +77,9 @@ const Account = React.createClass({ handleMention () { this.props.dispatch(mentionCompose(this.props.account)); + if (isMobile(window.innerWidth)) { + this.context.router.push('/statuses/new'); + } }, render () { diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx index 7a3dbe160d979fa223994a8d5e3a16da4b65bcb7..5c09839f715908086b3bfc8856ce6cf52323afd0 100644 --- a/app/assets/javascripts/components/features/account_timeline/index.jsx +++ b/app/assets/javascripts/components/features/account_timeline/index.jsx @@ -9,7 +9,8 @@ import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]), + statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items']), + isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']), me: state.getIn(['meta', 'me']) }); @@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({ propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list + statusIds: ImmutablePropTypes.list, + isLoading: React.PropTypes.bool, + me: React.PropTypes.number.isRequired }, mixins: [PureRenderMixin], @@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({ }, render () { - const { statusIds, me } = this.props; + const { statusIds, isLoading, me } = this.props; if (!statusIds) { return <LoadingIndicator />; } - return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} /> + return <StatusList statusIds={statusIds} isLoading={isLoading} me={me} onScrollToBottom={this.handleScrollToBottom} /> } }); diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 55f361b0b775a4e753b9f0e9ac0a60abb54720e2..48363a9687db450fc7940819a55c7c9d4542dd88 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, publish: { id: 'compose_form.publish', defaultMessage: 'Publish' } }); @@ -25,6 +26,8 @@ const ComposeForm = React.createClass({ suggestion_token: React.PropTypes.string, suggestions: ImmutablePropTypes.list, sensitive: React.PropTypes.bool, + spoiler: React.PropTypes.bool, + spoiler_text: React.PropTypes.string, unlisted: React.PropTypes.bool, private: React.PropTypes.bool, fileDropDate: React.PropTypes.instanceOf(Date), @@ -32,6 +35,7 @@ const ComposeForm = React.createClass({ is_uploading: React.PropTypes.bool, in_reply_to: ImmutablePropTypes.map, media_count: React.PropTypes.number, + me: React.PropTypes.number, onChange: React.PropTypes.func.isRequired, onSubmit: React.PropTypes.func.isRequired, onCancelReply: React.PropTypes.func.isRequired, @@ -39,6 +43,8 @@ const ComposeForm = React.createClass({ onFetchSuggestions: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired, onChangeSensitivity: React.PropTypes.func.isRequired, + onChangeSpoilerness: React.PropTypes.func.isRequired, + onChangeSpoilerText: React.PropTypes.func.isRequired, onChangeVisibility: React.PropTypes.func.isRequired, onChangeListability: React.PropTypes.func.isRequired, }, @@ -49,7 +55,7 @@ const ComposeForm = React.createClass({ this.props.onChange(e.target.value); }, - handleKeyUp (e) { + handleKeyDown (e) { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { this.props.onSubmit(); } @@ -76,6 +82,15 @@ const ComposeForm = React.createClass({ this.props.onChangeSensitivity(e.target.checked); }, + handleChangeSpoilerness (e) { + this.props.onChangeSpoilerness(e.target.checked); + this.props.onChangeSpoilerText(''); + }, + + handleChangeSpoilerText (e) { + this.props.onChangeSpoilerText(e.target.value); + }, + handleChangeVisibility (e) { this.props.onChangeVisibility(e.target.checked); }, @@ -85,7 +100,14 @@ const ComposeForm = React.createClass({ }, componentDidUpdate (prevProps) { - if (prevProps.in_reply_to !== this.props.in_reply_to) { + if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) { + // If replying to zero or one users, places the cursor at the end of the textbox. + // If replying to more than one user, selects any usernames past the first; + // this provides a convenient shortcut to drop everyone else from the conversation. + const selectionStart = this.props.text.search(/\s/) + 1; + const selectionEnd = this.props.text.length; + + this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); } }, @@ -103,8 +125,18 @@ const ComposeForm = React.createClass({ replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; } + let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); + return ( <div style={{ padding: '10px' }}> + <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}> + {({ opacity, height }) => + <div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" /> + </div> + } + </Motion> + {replyArea} <AutosuggestTextarea @@ -115,7 +147,7 @@ const ComposeForm = React.createClass({ value={this.props.text} onChange={this.handleChange} suggestions={this.props.suggestions} - onKeyUp={this.handleKeyUp} + onKeyDown={this.handleKeyDown} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} @@ -123,7 +155,7 @@ const ComposeForm = React.createClass({ <div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div> - <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div> + <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> <UploadButtonContainer style={{ paddingTop: '4px' }} /> </div> @@ -132,7 +164,12 @@ const ComposeForm = React.createClass({ <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> </label> - <Motion defaultStyle={{ opacity: this.props.private ? 0 : 100, height: this.props.private ? 39.5 : 0 }} style={{ opacity: spring(this.props.private ? 0 : 100), height: spring(this.props.private ? 0 : 39.5) }}> + <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}> + <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} /> + <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></span> + </label> + + <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}> {({ opacity, height }) => <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} /> diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx index d31d0e45356aa7f41138fc66f67e2ff900cee615..d0e865d2931662a1ea317ffed03a5847cfb66fbd 100644 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx @@ -1,26 +1,75 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; -const style = { +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const outerStyle = { + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + overflowY: 'hidden' +}; + +const innerStyle = { boxSizing: 'border-box', - background: '#454b5e', padding: '0', display: 'flex', flexDirection: 'column', - overflowY: 'auto' + overflowY: 'auto', + flexGrow: '1' +}; + +const tabStyle = { + display: 'block', + flex: '1 1 auto', + padding: '15px', + paddingBottom: '13px', + color: '#9baec8', + textDecoration: 'none', + textAlign: 'center', + fontSize: '16px', + borderBottom: '2px solid transparent' }; -const Drawer = React.createClass({ +const tabActiveStyle = { + color: '#2b90d9', + borderBottom: '2px solid #2b90d9' +}; - mixins: [PureRenderMixin], +const Drawer = ({ children, withHeader, intl }) => { + let header = ''; - render () { - return ( - <div className='drawer' style={style}> - {this.props.children} + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + <Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> + <a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> + <a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> </div> ); } -}); + return ( + <div className='drawer' style={outerStyle}> + {header} + + <div className='drawer__inner' style={innerStyle}> + {children} + </div> + </div> + ); +}; + +Drawer.propTypes = { + withHeader: React.PropTypes.bool, + children: React.PropTypes.node, + intl: React.PropTypes.object +}; -export default Drawer; +export default injectIntl(Drawer); diff --git a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx index df94c30d22900e70f7f1c6647eb1e6e6b1b6f5ce..289e2dddf155c25da2dd42fbb42c8d4245a1be3d 100644 --- a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx +++ b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx @@ -16,12 +16,12 @@ const NavigationBar = React.createClass({ render () { return ( - <div style={{ padding: '10px', display: 'flex', cursor: 'default' }}> + <div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink> <div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}> <strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong> - <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.settings' defaultMessage='Settings' /></a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.public_timeline' defaultMessage='Public timeline' /></Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a> + <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> </div> </div> ); diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx index b4e618820e12a0c8deaeb6df1ca396d38c41f4c0..e4672216b4aff292a0d247050081b9e672f41f5b 100644 --- a/app/assets/javascripts/components/features/compose/components/search.jsx +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -38,7 +38,7 @@ const inputStyle = { border: 'none', padding: '10px', paddingRight: '30px', - fontFamily: 'Roboto', + fontFamily: 'inherit', background: '#282c37', color: '#9baec8', fontSize: '14px', diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx index 5250ff7482c9cabab14c4af7002c042a0bd19ac4..4c8181aa13ba07444c65a690200e67e2b75c4301 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx @@ -11,7 +11,9 @@ const UploadButton = React.createClass({ propTypes: { disabled: React.PropTypes.bool, onSelectFile: React.PropTypes.func.isRequired, - style: React.PropTypes.object + style: React.PropTypes.object, + resetFileKey: React.PropTypes.number, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -31,12 +33,12 @@ const UploadButton = React.createClass({ }, render () { - const { intl } = this.props; + const { intl, resetFileKey, disabled } = this.props; return ( <div style={this.props.style}> - <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} /> - <input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} /> + <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} /> + <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} /> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx index ac548033cb48c7017983b10e72c30a6222e6bb83..94c94b4b749beb33603c5e29c4a0bc9d21e62b75 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx @@ -12,15 +12,20 @@ const UploadForm = React.createClass({ propTypes: { media: ImmutablePropTypes.list.isRequired, is_uploading: React.PropTypes.bool, - onRemoveFile: React.PropTypes.func.isRequired + onRemoveFile: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], render () { - const { intl } = this.props; + const { intl, media } = this.props; - const uploads = this.props.media.map(attachment => ( + if (!media.size) { + return null; + } + + const uploads = media.map(attachment => ( <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'> <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}> <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} /> @@ -29,7 +34,7 @@ const UploadForm = React.createClass({ )); return ( - <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}> + <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}> {uploads} </div> ); diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 2b6ee1ae7c5825c4d59f9c15102df1c934dbc6e8..8ccfce05982f58e3b541044c7993a519bbd8074a 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -8,6 +8,8 @@ import { fetchComposeSuggestions, selectComposeSuggestion, changeComposeSensitivity, + changeComposeSpoilerness, + changeComposeSpoilerText, changeComposeVisibility, changeComposeListability } from '../../../actions/compose'; @@ -22,13 +24,16 @@ const makeMapStateToProps = () => { suggestion_token: state.getIn(['compose', 'suggestion_token']), suggestions: state.getIn(['compose', 'suggestions']), sensitive: state.getIn(['compose', 'sensitive']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), unlisted: state.getIn(['compose', 'unlisted']), private: state.getIn(['compose', 'private']), fileDropDate: state.getIn(['compose', 'fileDropDate']), is_submitting: state.getIn(['compose', 'is_submitting']), is_uploading: state.getIn(['compose', 'is_uploading']), in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])), - media_count: state.getIn(['compose', 'media_attachments']).size + media_count: state.getIn(['compose', 'media_attachments']).size, + me: state.getIn(['compose', 'me']) }; }; @@ -65,6 +70,14 @@ const mapDispatchToProps = function (dispatch) { dispatch(changeComposeSensitivity(checked)); }, + onChangeSpoilerness (checked) { + dispatch(changeComposeSpoilerness(checked)); + }, + + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, + onChangeVisibility (checked) { dispatch(changeComposeVisibility(checked)); }, diff --git a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx index 51e2513d8405f8ce6b9d228664740e8c1879897d..0006608dacc24fd6969765fc424eb953a7c44915 100644 --- a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx @@ -1,8 +1,10 @@ import { connect } from 'react-redux'; import NavigationBar from '../components/navigation_bar'; -const mapStateToProps = (state, props) => ({ - account: state.getIn(['accounts', state.getIn(['meta', 'me'])]) -}); +const mapStateToProps = (state, props) => { + return { + account: state.getIn(['accounts', state.getIn(['meta', 'me'])]) + }; +}; export default connect(mapStateToProps)(NavigationBar); diff --git a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx index 4154b073721e035c5bbfbf77c43f14f0cb99c9e3..78e5312f571e279bf4c0e23be6d48b6844adc85d 100644 --- a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx @@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose'; const mapStateToProps = state => ({ disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']) }); const mapDispatchToProps = dispatch => ({ diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index 4017c8949f2f8ccb879d3c9b939dcee6fbfe3fc3..f6095c0c6d7b7dfd70c649eb11a653da70d53037 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose'; const Compose = React.createClass({ propTypes: { - dispatch: React.PropTypes.func.isRequired + dispatch: React.PropTypes.func.isRequired, + withHeader: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -25,7 +26,7 @@ const Compose = React.createClass({ render () { return ( - <Drawer> + <Drawer withHeader={this.props.withHeader}> <SearchContainer /> <NavigationContainer /> <ComposeFormContainer /> diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a2d5217365bca5b300015ade91ba9bad9e4533b1 --- /dev/null +++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx @@ -0,0 +1,63 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; +import Column from '../ui/components/column'; +import StatusList from '../../components/status_list'; +import ColumnBackButton from '../public_timeline/components/column_back_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' } +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + loaded: state.getIn(['status_lists', 'favourites', 'loaded']), + me: state.getIn(['meta', 'me']) +}); + +const Favourites = React.createClass({ + + propTypes: { + params: React.PropTypes.object.isRequired, + dispatch: React.PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + loaded: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired, + me: React.PropTypes.number.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + }, + + handleScrollToBottom () { + this.props.dispatch(expandFavouritedStatuses()); + }, + + render () { + const { statusIds, loaded, intl, me } = this.props; + + if (!loaded) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='star' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButton /> + <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} /> + </Column> + ); + } + +}); + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/assets/javascripts/components/features/generic_not_found/index.jsx b/app/assets/javascripts/components/features/generic_not_found/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a7afe29b0aef719b622e5991b65bb364cad63713 --- /dev/null +++ b/app/assets/javascripts/components/features/generic_not_found/index.jsx @@ -0,0 +1,10 @@ +import Column from '../ui/components/column'; +import MissingIndicator from '../../components/missing_indicator'; + +const GenericNotFound = () => ( + <Column> + <MissingIndicator /> + </Column> +); + +export default GenericNotFound; diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 157bdf8f2ff1f702153dc1139970d123cf5da9c7..42e0a9e2484cdbf8db9f212c0c95daac4d8100db 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -8,25 +8,16 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, - settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' }, - follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' } + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' } }); const mapStateToProps = state => ({ me: state.getIn(['accounts', state.getIn(['meta', 'me'])]) }); -const hamburgerStyle = { - background: '#373b4a', - color: '#fff', - fontSize: '16px', - padding: '15px', - position: 'absolute', - right: '0', - top: '-48px', - cursor: 'default' -}; - const GettingStarted = ({ intl, me }) => { let followRequests = ''; @@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => { return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}> <div style={{ position: 'relative' }}> - <div style={hamburgerStyle}><i className='fa fa-bars' /></div> <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> - <ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> {followRequests} + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> </div> - <div className='static-content'> - <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> - <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> - <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> + <div className='scrollable optionally-scrollable'> + <div className='static-content getting-started'> + <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> + <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> + <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> + <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p> + </div> </div> - - <div className='getting-started__illustration' /> </Column> ); }; diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx new file mode 100644 index 0000000000000000000000000000000000000000..714be309bcf40177128895df6c73d4817ad07794 --- /dev/null +++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx @@ -0,0 +1,68 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from '../../notifications/components/setting_toggle'; +import SettingText from './setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' } +}); + +const outerStyle = { + background: '#373b4a', + padding: '15px' +}; + +const sectionStyle = { + cursor: 'default', + display: 'block', + fontWeight: '500', + color: '#9baec8', + marginBottom: '10px' +}; + +const rowStyle = { + +}; + +const ColumnSettings = React.createClass({ + + propTypes: { + settings: ImmutablePropTypes.map.isRequired, + onChange: React.PropTypes.func.isRequired, + onSave: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { settings, onChange, onSave, intl } = this.props; + + return ( + <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}> + <div style={outerStyle}> + <span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} /> + </div> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div style={rowStyle}> + <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + </ColumnCollapsable> + ); + } + +}); + +export default injectIntl(ColumnSettings); diff --git a/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx new file mode 100644 index 0000000000000000000000000000000000000000..79697e8698c1b5e211e3c54535fb45a93cf110e5 --- /dev/null +++ b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx @@ -0,0 +1,41 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const style = { + display: 'block', + fontFamily: 'inherit', + marginBottom: '10px', + padding: '7px 0', + boxSizing: 'border-box', + width: '100%' +}; + +const SettingText = React.createClass({ + + propTypes: { + settings: ImmutablePropTypes.map.isRequired, + settingKey: React.PropTypes.array.isRequired, + label: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired + }, + + handleChange (e) { + this.props.onChange(this.props.settingKey, e.target.value) + }, + + render () { + const { settings, settingKey, label } = this.props; + + return ( + <input + style={style} + className='setting-text' + value={settings.getIn(settingKey)} + onChange={this.handleChange} + placeholder={label} + /> + ); + } + +}); + +export default SettingText; diff --git a/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3b3ce19bc07d35101b38b0fa9e983979e486baed --- /dev/null +++ b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['home', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx index e4f4fa7c7105ff5e3ad4a18647b9b7b9514fc1c3..5d2263f1562fb077790061fb99ad69278940774e 100644 --- a/app/assets/javascripts/components/features/home_timeline/index.jsx +++ b/app/assets/javascripts/components/features/home_timeline/index.jsx @@ -1,9 +1,8 @@ -import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../ui/components/column'; -import { refreshTimeline } from '../../actions/timelines'; import { defineMessages, injectIntl } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' } @@ -12,20 +11,17 @@ const messages = defineMessages({ const HomeTimeline = React.createClass({ propTypes: { - dispatch: React.PropTypes.func.isRequired + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], - componentWillMount () { - this.props.dispatch(refreshTimeline('home')); - }, - render () { const { intl } = this.props; return ( <Column icon='home' heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> <StatusListContainer {...this.props} type='home' /> </Column> ); @@ -33,4 +29,4 @@ const HomeTimeline = React.createClass({ }); -export default connect()(injectIntl(HomeTimeline)); +export default injectIntl(HomeTimeline); diff --git a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx index b4035c20d5af8861f364703f5d78ba45c9ccf617..b63c1881ac0aa2fb333545f0d86f231a90bc1c26 100644 --- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx @@ -1,37 +1,14 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Toggle from 'react-toggle'; -import { Motion, spring } from 'react-motion'; import { FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from './setting_toggle'; const outerStyle = { background: '#373b4a', padding: '15px' }; -const iconStyle = { - fontSize: '16px', - padding: '15px', - position: 'absolute', - right: '0', - top: '-48px', - cursor: 'pointer' -}; - -const labelStyle = { - display: 'block', - lineHeight: '24px', - verticalAlign: 'middle' -}; - -const labelSpanStyle = { - display: 'inline-block', - verticalAlign: 'middle', - marginBottom: '14px', - marginLeft: '8px', - color: '#9baec8' -}; - const sectionStyle = { cursor: 'default', display: 'block', @@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({ propTypes: { settings: ImmutablePropTypes.map.isRequired, - onChange: React.PropTypes.func.isRequired - }, - - getInitialState () { - return { - collapsed: true - }; + onChange: React.PropTypes.func.isRequired, + onSave: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], - handleToggleCollapsed () { - this.setState({ collapsed: !this.state.collapsed }); - }, - - handleChange (key, e) { - this.props.onChange(key, e.target.checked); - }, - render () { - const { settings } = this.props; - const { collapsed } = this.state; + const { settings, onChange, onSave } = this.props; 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' />; return ( - <div style={{ position: 'relative' }}> - <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div> - - <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : 458) }}> - {({ opacity, height }) => - <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}> - <div style={outerStyle}> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'mention'])} onChange={this.handleChange.bind(this, ['shows', 'mention'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> - - <div style={rowStyle}> - <label style={labelStyle}> - <Toggle checked={settings.getIn(['alerts', 'reblog'])} onChange={this.handleChange.bind(this, ['alerts', 'reblog'])} /> - <span style={labelSpanStyle}>{alertStr}</span> - </label> - - <label style={labelStyle}> - <Toggle checked={settings.getIn(['shows', 'reblog'])} onChange={this.handleChange.bind(this, ['shows', 'reblog'])} /> - <span style={labelSpanStyle}>{showStr}</span> - </label> - </div> - </div> - </div> - } - </Motion> - </div> + <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}> + <div style={outerStyle}> + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + + <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div style={rowStyle}> + <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + </ColumnCollapsable> ); } diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx index 9f4cf9e4df3d8f5a6a28b8080968dec23548c11a..140ba9134e95aedf77efc2639a03d1b6ac0bddeb 100644 --- a/app/assets/javascripts/components/features/notifications/components/notification.jsx +++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx @@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; const messageStyle = { marginLeft: '68px', @@ -71,7 +73,7 @@ const Notification = React.createClass({ <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} /> </div> - <FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} /> + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> <StatusContainer id={notification.get('status')} muted={true} /> @@ -83,7 +85,8 @@ const Notification = React.createClass({ const { notification } = this.props; const account = notification.get('account'); const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); - const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`}>{displayName}</Permalink>; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />; switch(notification.get('type')) { case 'follow': diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c2438f716ea2ac4a463d94f2dfaa9154d7c8c946 --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx @@ -0,0 +1,32 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +const labelStyle = { + display: 'block', + lineHeight: '24px', + verticalAlign: 'middle' +}; + +const labelSpanStyle = { + display: 'inline-block', + verticalAlign: 'middle', + marginBottom: '14px', + marginLeft: '8px', + color: '#9baec8' +}; + +const SettingToggle = ({ settings, settingKey, label, onChange }) => ( + <label style={labelStyle}> + <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} /> + <span style={labelSpanStyle}>{label}</span> + </label> +); + +SettingToggle.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: React.PropTypes.array.isRequired, + label: React.PropTypes.node.isRequired, + onChange: React.PropTypes.func.isRequired +}; + +export default SettingToggle; diff --git a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx index 6907fd35168fcfae30f491757ed6c53f9bcada94..bc24c75e0a44250e15e577f1090a489afd53a403 100644 --- a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx +++ b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx @@ -1,15 +1,19 @@ import { connect } from 'react-redux'; import ColumnSettings from '../components/column_settings'; -import { changeNotificationsSetting } from '../../../actions/notifications'; +import { changeSetting, saveSettings } from '../../../actions/settings'; const mapStateToProps = state => ({ - settings: state.getIn(['notifications', 'settings']) + settings: state.getIn(['settings', 'notifications']) }); const mapDispatchToProps = dispatch => ({ onChange (key, checked) { - dispatch(changeNotificationsSetting(key, checked)); + dispatch(changeSetting(['notifications', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); } }); diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx index 7e706ad6a2ad07442b5a259e6573e5f547a77217..b4593aaff5cb40bb6ffeff02c7bce20fc904e316 100644 --- a/app/assets/javascripts/components/features/notifications/index.jsx +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -2,10 +2,7 @@ import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../ui/components/column'; -import { - refreshNotifications, - expandNotifications -} from '../../actions/notifications'; +import { expandNotifications } from '../../actions/notifications'; import NotificationContainer from './containers/notification_container'; import { ScrollContainer } from 'react-router-scroll'; import { defineMessages, injectIntl } from 'react-intl'; @@ -18,12 +15,13 @@ const messages = defineMessages({ }); const getNotifications = createSelector([ - state => Immutable.List(state.getIn(['notifications', 'settings', 'shows']).filter(item => !item).keys()), + state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), state => state.getIn(['notifications', 'items']) ], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); const mapStateToProps = state => ({ - notifications: getNotifications(state) + notifications: getNotifications(state), + isLoading: state.getIn(['notifications', 'isLoading'], true) }); const Notifications = React.createClass({ @@ -32,7 +30,8 @@ const Notifications = React.createClass({ notifications: ImmutablePropTypes.list.isRequired, dispatch: React.PropTypes.func.isRequired, trackScroll: React.PropTypes.bool, - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + isLoading: React.PropTypes.bool }, getDefaultProps () { @@ -43,15 +42,11 @@ const Notifications = React.createClass({ mixins: [PureRenderMixin], - componentWillMount () { - const { dispatch } = this.props; - dispatch(refreshNotifications()); - }, - handleScroll (e) { const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; - if (scrollTop === scrollHeight - clientHeight) { + if (250 > offset && !this.props.isLoading) { this.props.dispatch(expandNotifications()); } }, @@ -70,6 +65,7 @@ const Notifications = React.createClass({ if (trackScroll) { return ( <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> <ScrollContainer scrollKey='notifications'> {scrollableArea} </ScrollContainer> diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx index 030428440829c7f42d75382a4dd015fc7683da40..3f8a0457dbebc7251330e6c9c0f6dcaa9ebbe67e 100644 --- a/app/assets/javascripts/components/features/status/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx @@ -61,8 +61,8 @@ const ActionBar = React.createClass({ <div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div> - <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> - <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div> + <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div> </div> ); } diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ccb06dfd5829bbb55b56091af80f04b628238529 --- /dev/null +++ b/app/assets/javascripts/components/features/status/components/card.jsx @@ -0,0 +1,100 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const outerStyle = { + display: 'flex', + cursor: 'pointer', + fontSize: '14px', + border: '1px solid #363c4b', + borderRadius: '4px', + color: '#616b86', + marginTop: '14px', + textDecoration: 'none', + overflow: 'hidden' +}; + +const contentStyle = { + flex: '1 1 auto', + padding: '8px', + paddingLeft: '14px', + overflow: 'hidden' +}; + +const titleStyle = { + display: 'block', + fontWeight: '500', + marginBottom: '5px', + color: '#d9e1e8', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' +}; + +const descriptionStyle = { + color: '#d9e1e8' +}; + +const imageOuterStyle = { + flex: '0 0 100px', + background: '#373b4a' +}; + +const imageStyle = { + display: 'block', + width: '100%', + height: 'auto', + margin: '0', + borderRadius: '4px 0 0 4px' +}; + +const hostStyle = { + display: 'block', + marginTop: '5px', + fontSize: '13px' +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const Card = React.createClass({ + propTypes: { + card: ImmutablePropTypes.map + }, + + mixins: [PureRenderMixin], + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + let image = ''; + + if (card.get('image')) { + image = ( + <div style={imageOuterStyle}> + <img src={card.get('image')} alt={card.get('title')} style={imageStyle} /> + </div> + ); + } + + return ( + <a style={outerStyle} href={card.get('url')} className='status-card'> + {image} + + <div style={contentStyle}> + <strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong> + <p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p> + <span style={hostStyle}>{getHostname(card.get('url'))}</span> + </div> + </a> + ); + } +}); + +export default Card; diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index b967d966f03f295368d1222defa3625cf850c091..f2d6ae48a8dee7d7e34477dcbd806a12cff87bdd 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery'; import VideoPlayer from '../../../components/video_player'; import { Link } from 'react-router'; import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; const DetailedStatus = React.createClass({ @@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({ render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; - let media = ''; + + let media = ''; + let applicationLink = ''; if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { @@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({ } else { media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; } + } else { + media = <CardContainer statusId={status.get('id')} />; + } + + if (status.get('application')) { + applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>; } return ( @@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({ {media} <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}> - <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link> + <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link> </div> </div> ); diff --git a/app/assets/javascripts/components/features/status/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5c8bfeec2a3dfe4449ba835717421428244e560c --- /dev/null +++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null) +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 0a1528fe97249348e5fead22563970a46aecb86f..38954984982aaf406f2285fb0f90cb5306d3547c 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -23,6 +23,7 @@ import { ScrollContainer } from 'react-router-scroll'; import ColumnBackButton from '../../components/column_back_button'; import StatusContainer from '../../containers/status_container'; import { openMedia } from '../../actions/modal'; +import { isMobile } from '../../is_mobile' const makeMapStateToProps = () => { const getStatus = makeGetStatus(); @@ -47,7 +48,8 @@ const Status = React.createClass({ dispatch: React.PropTypes.func.isRequired, status: ImmutablePropTypes.map, ancestorsIds: ImmutablePropTypes.list, - descendantsIds: ImmutablePropTypes.list + descendantsIds: ImmutablePropTypes.list, + me: React.PropTypes.number }, mixins: [PureRenderMixin], @@ -80,6 +82,10 @@ const Status = React.createClass({ handleMentionClick (account) { this.props.dispatch(mentionCompose(account)); + + if (isMobile(window.innerWidth)) { + this.context.router.push('/statuses/new'); + } }, handleOpenMedia (url) { diff --git a/app/assets/javascripts/components/features/ui/components/column_link.jsx b/app/assets/javascripts/components/features/ui/components/column_link.jsx index a2f7c13a6ba6adb9fe13ace4f4dedb95de4ea5b5..901a29f5c14b8ad4cd436b8328f59b79dacc4377 100644 --- a/app/assets/javascripts/components/features/ui/components/column_link.jsx +++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx @@ -13,10 +13,10 @@ const iconStyle = { marginRight: '5px' }; -const ColumnLink = ({ icon, text, to, href }) => { +const ColumnLink = ({ icon, text, to, href, method }) => { if (href) { return ( - <a href={href} style={outerStyle} className='column-link'> + <a href={href} style={outerStyle} className='column-link' data-method={method}> <i className={`fa fa-fw fa-${icon}`} style={iconStyle} /> {text} </a> diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx index 219979522b30d1f74a29b02c599f90db41d2aef2..2f8a28fadcda31d0fe60dd10e9ff78b4c41e29a0 100644 --- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx @@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl'; const outerStyle = { background: '#373b4a', - margin: '10px', flex: '0 0 auto', - marginBottom: '0' + overflowY: 'auto' }; const tabStyle = { display: 'block', flex: '1 1 auto', - padding: '10px', + padding: '10px 5px', color: '#fff', textDecoration: 'none', textAlign: 'center', @@ -31,7 +30,7 @@ const TabsBar = () => { <Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> - <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link> + <Link style={{ ...tabStyle, flexGrow: '0', flexBasis: '30px' }} activeStyle={tabActiveStyle} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> </div> ); }; diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx index cd7d63a4a9c0b98ba0c96f8318a90d5285ed1b78..53d1624629b0d2538f352d70090cb86e09bfd2d2 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -1,6 +1,9 @@ -import { connect } from 'react-redux'; -import { closeModal } from '../../../actions/modal'; -import Lightbox from '../../../components/lightbox'; +import { connect } from 'react-redux'; +import { closeModal } from '../../../actions/modal'; +import Lightbox from '../../../components/lightbox'; +import ImageLoader from 'react-imageloader'; +import LoadingIndicator from '../../../components/loading_indicator'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; const mapStateToProps = state => ({ url: state.getIn(['modal', 'url']), @@ -23,6 +26,18 @@ const imageStyle = { maxHeight: '80vh' }; +const loadingStyle = { + background: '#373b4a', + width: '400px', + paddingBottom: '120px' +}; + +const preloader = () => ( + <div style={loadingStyle}> + <LoadingIndicator /> + </div> +); + const Modal = React.createClass({ propTypes: { @@ -32,12 +47,18 @@ const Modal = React.createClass({ onOverlayClicked: React.PropTypes.func }, + mixins: [PureRenderMixin], + render () { const { url, ...other } = this.props; return ( <Lightbox {...other}> - <img src={url} style={imageStyle} /> + <ImageLoader + src={url} + preloader={preloader} + imgProps={{ style: imageStyle }} + /> </Lightbox> ); } diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 1621cec7b33ae88eef4a96cb4d8f37afeadb5778..8af7b0c3ca546fb18769e7328164348d1ede7a9a 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -2,26 +2,56 @@ import { connect } from 'react-redux'; import StatusList from '../../../components/status_list'; import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; +import { createSelector } from 'reselect'; + +const getStatusIds = createSelector([ + (state, { type }) => state.getIn(['settings', type], Immutable.Map()), + (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), + (state) => state.get('statuses') +], (columnSettings, statusIds, statuses) => statusIds.filter(id => { + const statusForId = statuses.get(id); + let showStatus = true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && statusForId.get('in_reply_to_id') === null; + } + + if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) { + try { + const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i'); + showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content')); + } catch(e) { + // Bad regex, don't affect filters + } + } + + return showStatus; +})); const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List()) + statusIds: getStatusIds(state, props), + isLoading: state.getIn(['timelines', props.type, 'isLoading'], true) }); -const mapDispatchToProps = function (dispatch, props) { - return { - onScrollToBottom () { - dispatch(scrollTopTimeline(props.type, false)); - dispatch(expandTimeline(props.type, props.id)); - }, +const mapDispatchToProps = (dispatch, { type, id }) => ({ - onScrollToTop () { - dispatch(scrollTopTimeline(props.type, true)); - }, + onScrollToBottom () { + dispatch(scrollTopTimeline(type, false)); + dispatch(expandTimeline(type, id)); + }, - onScroll () { - dispatch(scrollTopTimeline(props.type, false)); - } - }; -}; + onScrollToTop () { + dispatch(scrollTopTimeline(type, true)); + }, + + onScroll () { + dispatch(scrollTopTimeline(type, false)); + } + +}); export default connect(mapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index 76e3dd940268c7e67d74f1bd6a194c52e6bc9f9f..003d061adde99e0852669104c513d0cc7856587e 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -8,12 +8,20 @@ import Compose from '../compose'; import TabsBar from './components/tabs_bar'; import ModalContainer from './containers/modal_container'; import Notifications from '../notifications'; +import { connect } from 'react-redux'; +import { isMobile } from '../../is_mobile'; import { debounce } from 'react-decoration'; import { uploadCompose } from '../../actions/compose'; -import { connect } from 'react-redux'; +import { refreshTimeline } from '../../actions/timelines'; +import { refreshNotifications } from '../../actions/notifications'; const UI = React.createClass({ + propTypes: { + dispatch: React.PropTypes.func.isRequired, + children: React.PropTypes.node + }, + getInitialState () { return { width: window.innerWidth @@ -41,7 +49,7 @@ const UI = React.createClass({ handleDrop (e) { e.preventDefault(); - if (e.dataTransfer) { + if (e.dataTransfer && e.dataTransfer.files.length === 1) { this.props.dispatch(uploadCompose(e.dataTransfer.files)); } }, @@ -50,6 +58,9 @@ const UI = React.createClass({ window.addEventListener('resize', this.handleResize, { passive: true }); window.addEventListener('dragover', this.handleDragOver); window.addEventListener('drop', this.handleDrop); + + this.props.dispatch(refreshTimeline('home')); + this.props.dispatch(refreshNotifications()); }, componentWillUnmount () { @@ -59,11 +70,9 @@ const UI = React.createClass({ }, render () { - const layoutBreakpoint = 1024; - let mountedColumns; - if (this.state.width <= layoutBreakpoint) { + if (isMobile(this.state.width)) { mountedColumns = ( <ColumnsArea> {this.props.children} @@ -72,7 +81,7 @@ const UI = React.createClass({ } else { mountedColumns = ( <ColumnsArea> - <Compose /> + <Compose withHeader={true} /> <HomeTimeline trackScroll={false} /> <Notifications trackScroll={false} /> {this.props.children} diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx new file mode 100644 index 0000000000000000000000000000000000000000..eaa6221e41ed935a23bc238338e2bf0e5bddc867 --- /dev/null +++ b/app/assets/javascripts/components/is_mobile.jsx @@ -0,0 +1,5 @@ +const LAYOUT_BREAKPOINT = 1024; + +export function isMobile(width) { + return width <= LAYOUT_BREAKPOINT; +}; diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx index 17b74e15d610df466377504cdb27a77ffdefa7ac..7d32824f14ef45228515e9a1a8faed5a56c709f1 100644 --- a/app/assets/javascripts/components/locales/de.jsx +++ b/app/assets/javascripts/components/locales/de.jsx @@ -8,6 +8,9 @@ const en = { "status.reblog": "Teilen", "status.favourite": "Favorisieren", "status.reblogged_by": "{name} teilte", + "status.sensitive_warning": "Sensible Inhalte", + "status.sensitive_toggle": "Klicken um zu zeigen", + "status.open": "Öffnen", "video_player.toggle_sound": "Ton umschalten", "account.mention": "Erwähnen", "account.edit_profile": "Profil bearbeiten", @@ -19,14 +22,17 @@ const en = { "account.follows": "Folgt", "account.followers": "Folger", "account.follows_you": "Folgt dir", + "account.requested": "Warte auf Erlaubnis", "getting_started.heading": "Erste Schritte", "getting_started.about_addressing": "Du kannst Leuten folgen, falls du ihren Nutzernamen und ihre Domain kennst, in dem du eine e-mail-artige Addresse in das Suchfeld oben an der Seite eingibst.", "getting_started.about_shortcuts": "Falls der Zielnutzer an derselben Domain ist wie du, funktioniert der Nutzername auch alleine. Das gilt auch für Erwähnungen in Beiträgen.", "getting_started.about_developer": "Der Entwickler des Projekts kann unter Gargron@mastodon.social gefunden werden", + "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", "column.home": "Home", "column.mentions": "Erwähnungen", "column.public": "Gesamtes Bekanntes Netz", "column.notifications": "Mitteilungen", + "column.follow_requests": "Folgeanfragen", "tabs_bar.compose": "Schreiben", "tabs_bar.home": "Home", "tabs_bar.mentions": "Erwähnungen", @@ -36,9 +42,12 @@ const en = { "compose_form.publish": "Veröffentlichen", "compose_form.sensitive": "Medien als sensitiv markieren", "compose_form.unlisted": "Öffentlich nicht auflisten", - "navigation_bar.settings": "Einstellungen", + "compose_form.private": "Als privat markieren", + "navigation_bar.edit_profile": "Profil bearbeiten", + "navigation_bar.preferences": "Einstellungen", "navigation_bar.public_timeline": "Öffentlich", "navigation_bar.logout": "Abmelden", + "navigation_bar.follow_requests": "Folgeanfragen", "reply_indicator.cancel": "Abbrechen", "search.placeholder": "Suche", "search.account": "Konto", @@ -48,7 +57,21 @@ const en = { "notification.follow": "{name} folgt dir", "notification.favourite": "{name} favorisierte deinen Status", "notification.reblog": "{name} teilte deinen Status", - "notification.mention": "{name} erwähnte dich" + "notification.mention": "{name} erwähnte dich", + "notifications.column_settings.alert": "Desktop-Benachrichtigunen", + "notifications.column_settings.show": "In der Spalte anzeigen", + "notifications.column_settings.follow": "Neue Folger:", + "notifications.column_settings.favourite": "Favorisierungen:", + "notifications.column_settings.mention": "Erwähnungen:", + "notifications.column_settings.reblog": "Geteilte Beiträge:", + "follow_request.authorize": "Erlauben", + "follow_request.reject": "Ablehnen", + "home.column_settings.basic": "Einfach", + "home.column_settings.advanced": "Fortgeschritten", + "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", + "home.column_settings.show_replies": "Antworten anzeigen", + "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", + "missing_indicator.label": "Nicht gefunden" }; export default en; diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 3d4a389196ddc29a35607a7fcf763d55a0de6cb1..92dcbaeb9cf5f789159762008d98ed9831dbc26e 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -17,7 +17,6 @@ const en = { "account.unfollow": "Unfollow", "account.block": "Block", "account.follow": "Follow", - "account.block": "Block", "account.posts": "Posts", "account.follows": "Follows", "account.followers": "Followers", @@ -27,6 +26,7 @@ const en = { "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.", "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.", "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social", + "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.", "column.home": "Home", "column.mentions": "Mentions", "column.public": "Public", @@ -40,7 +40,9 @@ const en = { "compose_form.publish": "Toot", "compose_form.sensitive": "Mark media as sensitive", "compose_form.private": "Mark as private", - "navigation_bar.settings": "Settings", + "compose_form.unlisted": "Do not display in public timeline", + "navigation_bar.edit_profile": "Edit profile", + "navigation_bar.preferences": "Preferences", "navigation_bar.public_timeline": "Public timeline", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancel", diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx index 6bd9b18edfac4015938eb346e330bfc9b707ab20..b75fb57d9233e704c15aace8683e75ef351c3d67 100644 --- a/app/assets/javascripts/components/locales/es.jsx +++ b/app/assets/javascripts/components/locales/es.jsx @@ -37,7 +37,8 @@ const es = { "compose_form.publish": "Publicar", "compose_form.sensitive": "Marcar el contenido como sensible", "compose_form.unlisted": "Privado", - "navigation_bar.settings": "Ajustes", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.preferences": "Preferencias", "navigation_bar.public_timeline": "Público", "navigation_bar.logout": "Cerrar sesión", "reply_indicator.cancel": "Cancelar", diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx index 968c3f8c394a96aa89d69a4aefcab92f18519397..183e5d5b524ccde81965a5cbebccbbf19195e210 100644 --- a/app/assets/javascripts/components/locales/fr.jsx +++ b/app/assets/javascripts/components/locales/fr.jsx @@ -38,7 +38,8 @@ const fr = { "compose_form.publish": "Pouet", "compose_form.sensitive": "Marquer le contenu comme délicat", "compose_form.unlisted": "Ne pas apparaître dans le fil public", - "navigation_bar.settings": "Paramètres", + "navigation_bar.edit_profile": "Modifier le profil", + "navigation_bar.preferences": "Préférences", "navigation_bar.public_timeline": "Public", "navigation_bar.logout": "Déconnexion", "reply_indicator.cancel": "Annuler", diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx index 606fc830fe83ceac7f565c918414ad352e7f4b8d..9a2d14d87af4fa2d321b65c7ff95db4206cbd456 100644 --- a/app/assets/javascripts/components/locales/hu.jsx +++ b/app/assets/javascripts/components/locales/hu.jsx @@ -38,7 +38,8 @@ const hu = { "compose_form.publish": "Tülk!", "compose_form.sensitive": "Tartalom érzékenynek jelölése", "compose_form.unlisted": "Listázatlan mód", - "navigation_bar.settings": "BeállÃtások", + "navigation_bar.edit_profile": "Profil szerkesztése", + "navigation_bar.preferences": "BeállÃtások", "navigation_bar.public_timeline": "Nyilvános idÅ‘folyam", "navigation_bar.logout": "Kijelentkezés", "reply_indicator.cancel": "Mégsem", diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx index 57cbcd31be09eca9c016be2276a352b58585a62e..d68724b13b08df8fd9cb7d86239de4ef89cf4c44 100644 --- a/app/assets/javascripts/components/locales/pt.jsx +++ b/app/assets/javascripts/components/locales/pt.jsx @@ -36,7 +36,8 @@ const pt = { "compose_form.publish": "Publicar", "compose_form.sensitive": "Marcar conteúdo como sensÃvel", "compose_form.unlisted": "Modo não-listado", - "navigation_bar.settings": "Configurações", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.preferences": "Preferências", "navigation_bar.public_timeline": "Timeline Pública", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancelar", diff --git a/app/assets/javascripts/components/locales/uk.jsx b/app/assets/javascripts/components/locales/uk.jsx index 53535c25acc8375c38d43e568389e9916d27b3ca..84a348c210a295be896a62268d3413a05f1c2fa1 100644 --- a/app/assets/javascripts/components/locales/uk.jsx +++ b/app/assets/javascripts/components/locales/uk.jsx @@ -38,7 +38,8 @@ const uk = { "compose_form.publish": "Дмухнути", "compose_form.sensitive": "ÐеприÑтойний зміÑÑ‚", "compose_form.unlisted": "Таємний режим", - "navigation_bar.settings": "ÐалаштуваннÑ", + "navigation_bar.edit_profile": "Редагувати профіль", + "navigation_bar.preferences": "ÐалаштуваннÑ", "navigation_bar.public_timeline": "Публічна Ñтіна", "navigation_bar.logout": "Вийти", "reply_indicator.cancel": "Відмінити", diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx index 3a1473bc14c4bb4dd2d2e3617c34b1ad7e3f4a43..74d77f0f9a1f772dd57c636736f74294d1344113 100644 --- a/app/assets/javascripts/components/middleware/errors.jsx +++ b/app/assets/javascripts/components/middleware/errors.jsx @@ -23,7 +23,7 @@ export default function errorsMiddleware() { dispatch(showAlert(title, message)); } else { console.error(action.error); - dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details')); + dispatch(showAlert('Oops!', 'An unexpected error occurred.')); } } } diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a98f1bb2b65e5f516cb923c55985b6fae6c977f7 --- /dev/null +++ b/app/assets/javascripts/components/middleware/loading_bar.jsx @@ -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/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 7f2f89d0a434fe17d38f38c6833eac753c70f70f..409dfd663de03a493ee602ce431f7e3ea465e58e 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -1,5 +1,4 @@ import { - ACCOUNT_SET_SELF, ACCOUNT_FETCH_SUCCESS, FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_EXPAND_SUCCESS, @@ -7,7 +6,9 @@ import { FOLLOWING_EXPAND_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS, - FOLLOW_REQUESTS_FETCH_SUCCESS + FOLLOW_REQUESTS_FETCH_SUCCESS, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS } from '../actions/accounts'; import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; import { @@ -33,6 +34,11 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account)); @@ -67,38 +73,45 @@ const initialState = Immutable.Map(); export default function accounts(state = initialState, action) { switch(action.type) { - case ACCOUNT_SET_SELF: - case ACCOUNT_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: - return normalizeAccount(state, action.account); - case FOLLOWERS_FETCH_SUCCESS: - case FOLLOWERS_EXPAND_SUCCESS: - case FOLLOWING_FETCH_SUCCESS: - case FOLLOWING_EXPAND_SUCCESS: - case REBLOGS_FETCH_SUCCESS: - case FAVOURITES_FETCH_SUCCESS: - case COMPOSE_SUGGESTIONS_READY: - case SEARCH_SUGGESTIONS_READY: - case FOLLOW_REQUESTS_FETCH_SUCCESS: - return normalizeAccounts(state, action.accounts); - case NOTIFICATIONS_REFRESH_SUCCESS: - case NOTIFICATIONS_EXPAND_SUCCESS: - return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); - case TIMELINE_REFRESH_SUCCESS: - case TIMELINE_EXPAND_SUCCESS: - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - return normalizeAccountsFromStatuses(state, action.statuses); - case REBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNREBLOG_SUCCESS: - case UNFAVOURITE_SUCCESS: - return normalizeAccountFromStatus(state, action.response); - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - return normalizeAccountFromStatus(state, action.status); - default: - return state; + case STORE_HYDRATE: + return state.merge(action.state.get('accounts')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case SEARCH_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return normalizeAccounts(state, action.accounts); + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + case ACCOUNT_FOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); + case ACCOUNT_UNFOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3c9395011e807a7df262e98ca7ee7384fb22a6f5 --- /dev/null +++ b/app/assets/javascripts/components/reducers/cards.jsx @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; + +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, Immutable.fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 16215684e79a8e3821349a6859086d7f3ff8d955..d3a84842fccf9152be2baf837f89334569336c9d 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -17,16 +17,20 @@ import { COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, COMPOSE_LISTABILITY_CHANGE } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; -import { ACCOUNT_SET_SELF } from '../actions/accounts'; +import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; const initialState = Immutable.Map({ mounted: false, sensitive: false, + spoiler: false, + spoiler_text: '', unlisted: false, private: false, text: '', @@ -38,7 +42,8 @@ const initialState = Immutable.Map({ media_attachments: Immutable.List(), suggestion_token: null, suggestions: Immutable.List(), - me: null + me: null, + resetFileKey: Math.floor((Math.random() * 0x10000)) }); function statusToTextMentions(state, status) { @@ -55,6 +60,8 @@ function statusToTextMentions(state, status) { function clearAll(state) { return state.withMutations(map => { map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); map.update('media_attachments', list => list.clear()); @@ -65,6 +72,7 @@ function appendMedia(state, media) { return state.withMutations(map => { map.update('media_attachments', list => list.push(media)); map.set('is_uploading', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim()); }); }; @@ -80,7 +88,7 @@ function removeMedia(state, mediaId) { const insertSuggestion = (state, position, token, completion) => { return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`); + map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', Immutable.List(), list => list.clear()); }); @@ -88,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => { export default function compose(state = initialState, action) { switch(action.type) { - case COMPOSE_MOUNT: - return state.set('mounted', true); - case COMPOSE_UNMOUNT: - return state.set('mounted', false); - case COMPOSE_SENSITIVITY_CHANGE: - return state.set('sensitive', action.checked); - case COMPOSE_VISIBILITY_CHANGE: - return state.set('private', action.checked); - case COMPOSE_LISTABILITY_CHANGE: - return state.set('unlisted', action.checked); - case COMPOSE_CHANGE: - return state.set('text', action.text); - case COMPOSE_REPLY: - return state.withMutations(map => { - map.set('in_reply_to', action.status.get('id')); - map.set('text', statusToTextMentions(state, action.status)); - }); - case COMPOSE_REPLY_CANCEL: - return state.withMutations(map => { - map.set('in_reply_to', null); - map.set('text', ''); - }); - case COMPOSE_SUBMIT_REQUEST: - return state.set('is_submitting', true); - case COMPOSE_SUBMIT_SUCCESS: - return clearAll(state); - case COMPOSE_SUBMIT_FAIL: - return state.set('is_submitting', false); - case COMPOSE_UPLOAD_REQUEST: - return state.withMutations(map => { - map.set('is_uploading', true); - map.set('fileDropDate', new Date()); - }); - case COMPOSE_UPLOAD_SUCCESS: - return appendMedia(state, Immutable.fromJS(action.media)); - case COMPOSE_UPLOAD_FAIL: - return state.set('is_uploading', false); - case COMPOSE_UPLOAD_UNDO: - return removeMedia(state, action.media_id); - case COMPOSE_UPLOAD_PROGRESS: - return state.set('progress', Math.round((action.loaded / action.total) * 100)); - case COMPOSE_MENTION: - return state.update('text', text => `${text}@${action.account.get('acct')} `); - case COMPOSE_SUGGESTIONS_CLEAR: - return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); - case COMPOSE_SUGGESTIONS_READY: - return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); - case COMPOSE_SUGGESTION_SELECT: - return insertSuggestion(state, action.position, action.token, action.completion); - case TIMELINE_DELETE: - if (action.id === state.get('in_reply_to')) { - return state.set('in_reply_to', null); - } else { - return state; - } - case ACCOUNT_SET_SELF: - return state.set('me', action.account.id).set('private', action.account.locked); - default: + case STORE_HYDRATE: + return state.merge(action.state.get('compose')); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); + case COMPOSE_SENSITIVITY_CHANGE: + return state.set('sensitive', action.checked); + case COMPOSE_SPOILERNESS_CHANGE: + return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state.set('spoiler_text', action.text); + case COMPOSE_VISIBILITY_CHANGE: + return state.set('private', action.checked); + case COMPOSE_LISTABILITY_CHANGE: + return state.set('unlisted', action.checked); + case COMPOSE_CHANGE: + return state.set('text', action.text); + case COMPOSE_REPLY: + return state.withMutations(map => { + map.set('in_reply_to', action.status.get('id')); + map.set('text', statusToTextMentions(state, action.status)); + }); + case COMPOSE_REPLY_CANCEL: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('text', ''); + }); + case COMPOSE_SUBMIT_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_SUBMIT_SUCCESS: + return clearAll(state); + case COMPOSE_SUBMIT_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_REQUEST: + return state.withMutations(map => { + map.set('is_uploading', true); + map.set('fileDropDate', new Date()); + }); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, Immutable.fromJS(action.media)); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case COMPOSE_MENTION: + return state.update('text', text => `${text}@${action.account.get('acct')} `); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else { return state; + } + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index aea9239f87aa39863a5223b158cf532bb69455fe..0798116c4b2959833f7e93d30f0836ad3a91dcca 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -11,6 +11,9 @@ import statuses from './statuses'; import relationships from './relationships'; import search from './search'; import notifications from './notifications'; +import settings from './settings'; +import status_lists from './status_lists'; +import cards from './cards'; export default combineReducers({ timelines, @@ -20,9 +23,12 @@ export default combineReducers({ loadingBar: loadingBarReducer, modal, user_lists, + status_lists, accounts, statuses, relationships, search, - notifications + notifications, + settings, + cards }); diff --git a/app/assets/javascripts/components/reducers/meta.jsx b/app/assets/javascripts/components/reducers/meta.jsx index c7222c60b0079327a118a99e1f877f4106731da2..cd4b313d53115cd95dd8cee4bb2fa68e63e37c01 100644 --- a/app/assets/javascripts/components/reducers/meta.jsx +++ b/app/assets/javascripts/components/reducers/meta.jsx @@ -1,16 +1,16 @@ -import { ACCESS_TOKEN_SET } from '../actions/meta'; -import { ACCOUNT_SET_SELF } from '../actions/accounts'; +import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; -const initialState = Immutable.Map(); +const initialState = Immutable.Map({ + access_token: null, + me: null +}); export default function meta(state = initialState, action) { switch(action.type) { - case ACCESS_TOKEN_SET: - return state.set('access_token', action.token); - case ACCOUNT_SET_SELF: - return state.set('me', action.account.id); - default: - return state; + case STORE_HYDRATE: + return state.merge(action.state.get('meta')); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index b529b6aa8f1c6c4b97c208c64f582d936adf1d98..ac53ea21028511bd29923e5612ba116a7ce36981 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -8,14 +8,14 @@ const initialState = Immutable.Map({ export default function modal(state = initialState, action) { switch(action.type) { - case MEDIA_OPEN: - return state.withMutations(map => { - map.set('url', action.url); - map.set('open', true); - }); - case MODAL_CLOSE: - return state.set('open', false); - default: - return state; + case MEDIA_OPEN: + return state.withMutations(map => { + map.set('url', action.url); + map.set('open', true); + }); + case MODAL_CLOSE: + return state.set('open', false); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index e0d1ccf831d81fc41f0bc40d6ea280aecf0d0ab7..482093c33b7a7c57b3c57a7b2e8afa28d512d526 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -2,7 +2,10 @@ import { NOTIFICATIONS_UPDATE, NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS, - NOTIFICATIONS_SETTING_CHANGE + NOTIFICATIONS_REFRESH_REQUEST, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_REFRESH_FAIL, + NOTIFICATIONS_EXPAND_FAIL } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -11,22 +14,7 @@ const initialState = Immutable.Map({ items: Immutable.List(), next: null, loaded: false, - - settings: Immutable.Map({ - alerts: Immutable.Map({ - follow: true, - favourite: true, - reblog: true, - mention: true - }), - - shows: Immutable.Map({ - follow: true, - favourite: true, - reblog: true, - mention: true - }) - }) + isLoading: true }); const notificationToMap = notification => Immutable.Map({ @@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => { items = items.set(i, notificationToMap(n)); }); - return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true); + return state + .update('items', list => loaded ? list.unshift(...items) : list.push(...items)) + .set('next', next) + .set('loaded', true) + .set('isLoading', false); }; const appendNormalizedNotifications = (state, notifications, next) => { @@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => { items = items.set(i, notificationToMap(n)); }); - return state.update('items', list => list.push(...items)).set('next', next); + return state + .update('items', list => list.push(...items)) + .set('next', next) + .set('isLoading', false); }; const filterNotifications = (state, relationship) => { @@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => { export default function notifications(state = initialState, action) { switch(action.type) { - case NOTIFICATIONS_UPDATE: - return normalizeNotification(state, action.notification); - case NOTIFICATIONS_REFRESH_SUCCESS: - return normalizeNotifications(state, action.notifications, action.next); - case NOTIFICATIONS_EXPAND_SUCCESS: - return appendNormalizedNotifications(state, action.notifications, action.next); - case ACCOUNT_BLOCK_SUCCESS: - return filterNotifications(state, action.relationship); - case NOTIFICATIONS_SETTING_CHANGE: - return state.setIn(['settings', ...action.key], action.checked); - default: - return state; + case NOTIFICATIONS_REFRESH_REQUEST: + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_REFRESH_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', true); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_REFRESH_SUCCESS: + return normalizeNotifications(state, action.notifications, action.next); + case NOTIFICATIONS_EXPAND_SUCCESS: + return appendNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, action.relationship); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx index 9c204186397cb782baba604da5035383667fc702..d835ef268225474118bc078cdeb51af7e0943dd7 100644 --- a/app/assets/javascripts/components/reducers/search.jsx +++ b/app/assets/javascripts/components/reducers/search.jsx @@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => { } ]; - if (value.indexOf('@') === -1) { + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) { newSuggestions.push({ title: 'hashtag', items: [ diff --git a/app/assets/javascripts/components/reducers/settings.jsx b/app/assets/javascripts/components/reducers/settings.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8acc3facaf001dbbd8e618476e0a04357dda0c6a --- /dev/null +++ b/app/assets/javascripts/components/reducers/settings.jsx @@ -0,0 +1,46 @@ +import { SETTING_CHANGE } from '../actions/settings'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + home: Immutable.Map({ + shows: Immutable.Map({ + reblog: true, + reply: true + }) + }), + + notifications: Immutable.Map({ + alerts: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + shows: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + sounds: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }) + }) +}); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.mergeDeep(action.state.get('settings')); + case SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b883b1c58006dd1bb27004cb6188f88f8e0fc41e --- /dev/null +++ b/app/assets/javascripts/components/reducers/status_lists.jsx @@ -0,0 +1,39 @@ +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + favourites: Immutable.Map({ + next: null, + loaded: false, + items: Immutable.List() + }) +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('items', Immutable.List(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('items', map.get('items').push(...statuses.map(item => item.id))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + 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); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index c740b6d64ae823e7f83d0f280bc6702250ac32de..084b6304c0cc3fc777405c8127e3612283e90213 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -28,6 +28,10 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; import Immutable from 'immutable'; const normalizeStatus = (state, status) => { @@ -77,36 +81,38 @@ const initialState = Immutable.Map(); export default function statuses(state = initialState, action) { switch(action.type) { - case TIMELINE_UPDATE: - case STATUS_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: - return normalizeStatus(state, action.status); - case REBLOG_SUCCESS: - case UNREBLOG_SUCCESS: - case FAVOURITE_SUCCESS: - case UNFAVOURITE_SUCCESS: - return normalizeStatus(state, action.response); - case FAVOURITE_REQUEST: - return state.setIn([action.status.get('id'), 'favourited'], true); - case FAVOURITE_FAIL: - return state.setIn([action.status.get('id'), 'favourited'], false); - case REBLOG_REQUEST: - return state.setIn([action.status.get('id'), 'reblogged'], true); - case REBLOG_FAIL: - return state.setIn([action.status.get('id'), 'reblogged'], false); - case TIMELINE_REFRESH_SUCCESS: - case TIMELINE_EXPAND_SUCCESS: - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - case CONTEXT_FETCH_SUCCESS: - case NOTIFICATIONS_REFRESH_SUCCESS: - case NOTIFICATIONS_EXPAND_SUCCESS: - return normalizeStatuses(state, action.statuses); - case TIMELINE_DELETE: - return deleteStatus(state, action.id, action.references); - case ACCOUNT_BLOCK_SUCCESS: - return filterStatuses(state, action.relationship); - default: - return state; + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + return filterStatuses(state, action.relationship); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index b73c83e0fbd55a1ee1ce51e00c5fd8d5b9d9e983..6f2d26dcb79b6cad4219c6a51c660f7819408d56 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -1,9 +1,12 @@ import { TIMELINE_REFRESH_REQUEST, TIMELINE_REFRESH_SUCCESS, + TIMELINE_REFRESH_FAIL, TIMELINE_UPDATE, TIMELINE_DELETE, TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, TIMELINE_SCROLL_TOP } from '../actions/timelines'; import { @@ -13,37 +16,43 @@ import { UNFAVOURITE_SUCCESS } from '../actions/interactions'; import { - ACCOUNT_FETCH_SUCCESS, + ACCOUNT_TIMELINE_FETCH_REQUEST, ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_FETCH_FAIL, + ACCOUNT_TIMELINE_EXPAND_REQUEST, ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_FAIL, ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import { - STATUS_FETCH_SUCCESS, CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.Map({ + isLoading: false, loaded: false, top: true, items: Immutable.List() }), mentions: Immutable.Map({ + isLoading: false, loaded: false, top: true, items: Immutable.List() }), public: Immutable.Map({ + isLoading: false, loaded: false, top: true, items: Immutable.List() }), tag: Immutable.Map({ + isLoading: false, id: null, loaded: false, top: true, @@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => { }); state = state.setIn([timeline, 'loaded'], true); + state = state.setIn([timeline, 'isLoading'], false); return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids)); }; @@ -94,6 +104,8 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { moreIds = moreIds.set(i, status.get('id')); }); + state = state.setIn([timeline, 'isLoading'], false); + return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; @@ -105,7 +117,10 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) = ids = ids.set(i, status.get('id')); }); - return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids))); + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .set('loaded', true) + .update('items', Immutable.List(), list => (replace ? ids : list.unshift(...ids)))); }; const appendNormalizedAccountTimeline = (state, accountId, statuses) => { @@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => { moreIds = moreIds.set(i, status.get('id')); }); - return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds)); + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .update('items', list => list.push(...moreIds))); }; const updateTimeline = (state, timeline, status, references) => { @@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => { return state; }; -const deleteStatus = (state, id, accountId, references) => { +const deleteStatus = (state, id, accountId, references, reblogOf) => { + if (reblogOf) { + // If we are deleting a reblog, just replace reblog with its original + return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item)); + } + // Remove references from timelines ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); // Remove references from account timelines - state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id)); + state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id)); // Remove references from context state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => { @@ -202,8 +224,11 @@ const resetTimeline = (state, timeline, id) => { if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { state = state.update(timeline, map => map .set('id', id) + .set('isLoading', true) .set('loaded', false) .update('items', list => list.clear())); + } else { + state = state.setIn([timeline, 'isLoading'], true); } return state; @@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => { export default function timelines(state = initialState, action) { switch(action.type) { - case TIMELINE_REFRESH_REQUEST: - return resetTimeline(state, action.timeline, action.id); - case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); - case TIMELINE_EXPAND_SUCCESS: - return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); - case TIMELINE_UPDATE: - return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); - case TIMELINE_DELETE: - return deleteStatus(state, action.id, action.accountId, action.references); - case CONTEXT_FETCH_SUCCESS: - return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants)); - case ACCOUNT_TIMELINE_FETCH_SUCCESS: - return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); - case ACCOUNT_TIMELINE_EXPAND_SUCCESS: - return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); - case ACCOUNT_BLOCK_SUCCESS: - return filterTimelines(state, action.relationship, action.statuses); - case TIMELINE_SCROLL_TOP: - return state.setIn([action.timeline, 'top'], action.top); - default: - return state; + case TIMELINE_REFRESH_REQUEST: + case TIMELINE_EXPAND_REQUEST: + return resetTimeline(state, action.timeline, action.id); + case TIMELINE_REFRESH_FAIL: + case TIMELINE_EXPAND_FAIL: + return state.setIn([action.timeline, 'isLoading'], false); + case TIMELINE_REFRESH_SUCCESS: + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + case TIMELINE_EXPAND_SUCCESS: + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants)); + case ACCOUNT_TIMELINE_FETCH_REQUEST: + case ACCOUNT_TIMELINE_EXPAND_REQUEST: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true)); + case ACCOUNT_TIMELINE_FETCH_FAIL: + case ACCOUNT_TIMELINE_EXPAND_FAIL: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false)); + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); + case ACCOUNT_BLOCK_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return state.setIn([action.timeline, 'top'], action.top); + default: + return state; } }; diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx index 36093663fc325a97ac660508014ce6291f321583..72922f509fec9d3a974404611f8b8647ec7ba335 100644 --- a/app/assets/javascripts/components/reducers/user_lists.jsx +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => { export default function userLists(state = initialState, action) { switch(action.type) { - case FOLLOWERS_FETCH_SUCCESS: - return normalizeList(state, 'followers', action.id, action.accounts, action.next); - case FOLLOWERS_EXPAND_SUCCESS: - return appendToList(state, 'followers', action.id, action.accounts, action.next); - case FOLLOWING_FETCH_SUCCESS: - return normalizeList(state, 'following', action.id, action.accounts, action.next); - case FOLLOWING_EXPAND_SUCCESS: - return appendToList(state, 'following', action.id, action.accounts, action.next); - case REBLOGS_FETCH_SUCCESS: - return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); - case FAVOURITES_FETCH_SUCCESS: - return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); - case FOLLOW_REQUESTS_FETCH_SUCCESS: - return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); - case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - case FOLLOW_REQUEST_REJECT_SUCCESS: - return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); - default: - return state; + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + default: + return state; } }; diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx index 3d03d4c193184f1ff77547c4d66848d3e132b1d3..ad0427b52f319cb93014009ae2ae9d991f2c7c49 100644 --- a/app/assets/javascripts/components/store/configureStore.jsx +++ b/app/assets/javascripts/components/store/configureStore.jsx @@ -1,11 +1,23 @@ import { createStore, applyMiddleware, compose } from 'redux'; -import thunk from 'redux-thunk'; -import appReducer from '../reducers'; -import { loadingBarMiddleware } from 'react-redux-loading-bar'; -import errorsMiddleware from '../middleware/errors'; +import thunk from 'redux-thunk'; +import appReducer from '../reducers'; +import loadingBarMiddleware from '../middleware/loading_bar'; +import errorsMiddleware from '../middleware/errors'; +import soundsMiddleware from 'redux-sounds'; +import Howler from 'howler'; +import Immutable from 'immutable'; -export default function configureStore(initialState) { - return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({ - promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'], - }), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f)); +Howler.mobileAutoEnable = false; + +const soundsData = { + boop: '/sounds/boop.mp3' +}; + +export default function configureStore() { + return createStore(appReducer, compose(applyMiddleware( + thunk, + loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), + errorsMiddleware(), + soundsMiddleware(soundsData) + ), window.devToolsExtension ? window.devToolsExtension() : f => f)); }; diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx index b9f8e6842cd43c28c92fb4e184eaaa626fe6eb1e..5738863ddd0ff4fc89dada6aacc30474af6f233b 100644 --- a/app/assets/javascripts/extras.jsx +++ b/app/assets/javascripts/extras.jsx @@ -1,7 +1,7 @@ import emojify from './components/emoji' $(() => { - $.each($('.entry .content, .entry .status__content, .status__display-name, .display-name, .name, .account__header__content'), (_, content) => { + $.each($('.emojify'), (_, content) => { const $content = $(content); $content.html(emojify($content.html())); }); @@ -19,8 +19,6 @@ $(() => { }); $('.webapp-btn').on('click', e => { - console.log(e); - if (e.button === 0) { e.preventDefault(); window.location.href = $(e.target).attr('href'); diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss index 3681672d8ca1e41addf13aa98d9a4ff11294362e..b7d903ddf90feb1b9f2d97e405d09c23bf164e15 100644 --- a/app/assets/stylesheets/about.scss +++ b/app/assets/stylesheets/about.scss @@ -1,20 +1,21 @@ -@import url(https://fonts.googleapis.com/css?family=Montserrat); -@import url(https://fonts.googleapis.com/css?family=Judson); - .about-body { .wrapper { max-width: 600px; margin: 0 auto; - color: #9baec8; + color: $color3; padding-top: 50px; padding-bottom: 50px; + + &.thicc { + max-width: 700px; + } } h1 { - font: 46px/52px 'Roboto', sans-serif; + font: 46px/52px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 600; margin-bottom: 20px; - color: #2b90d9; + color: $color4; padding: 20px 0; img { @@ -26,17 +27,21 @@ } h2 { - font: 24px/28px 'Judson', sans-serif; - font-weight: 300; + font-family: 'Montserrat', sans-serif; + font-size: 24px; + line-height: 28px; + font-weight: 400; margin-bottom: 20px; - color: #fff; + color: $color5; } h3 { - font: 20px/28px 'Judson', sans-serif; - font-weight: 300; + font-family: 'Montserrat', sans-serif; + font-size: 20px; + line-height: 28px; + font-weight: 400; margin-bottom: 20px; - color: #d9e1e8; + color: $color2; } ul, ol { @@ -57,12 +62,12 @@ } p, li { - font: 20px/28px 'Judson', sans-serif; - font-weight: 300; + font: 16px/28px 'Montserrat', sans-serif; + font-weight: 400; margin-bottom: 26px; a { - color: #2b90d9; + color: $color4; text-decoration: underline; } } @@ -70,14 +75,15 @@ em { display: inline-block; padding: 7px 7px 5px 7px; - background: #9baec8; - color: #282c37; + margin: 0 2px; + background: $color3; + color: $color1; font: 16px/16px 'Montserrat', sans-serif; font-weight: 300; } .screenshot { - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 15px rgba($color8, 0.4); margin-bottom: 26px; img { @@ -96,7 +102,7 @@ line-height: 36px; a { - color: #9baec8; + color: $color3; text-decoration: underline; } } @@ -108,3 +114,162 @@ } } } + +.information-board { + margin: 20px 0; + display: flex; + justify-content: space-between; + border-top: 1px solid lighten($color1, 10%); + border-bottom: 1px solid lighten($color1, 10%); + padding-right: 14px; + + .section { + flex: 1 0 0; + padding: 14px; + text-align: right; + font: 16px/28px 'Montserrat', sans-serif; + + span, strong { + display: block; + } + + span { + font-size: 16px; + + &:last-child { + color: $color2; + font-size: 14px; + } + } + + strong { + font-weight: 500; + font-size: 32px; + line-height: 48px; + color: $color5; + } + } +} + +.owner { + text-align: center; + + .avatar { + width: 80px; + height: 80px; + margin: 0 auto; + margin-bottom: 15px; + + img { + display: block; + width: 80px; + height: 80px; + border-radius: 48px; + } + } + + .name { + font-size: 14px; + + a { + display: block; + color: $color5; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + + .username { + display: block; + color: $color3; + } + } +} + +.contact-email { + text-align: center; + margin: 40px 0; + + strong { + display: block; + color: $color5; + } +} + +.sidebar-layout { + display: flex; + + .main { + flex: 1 1 auto; + padding: 14px 0; + + .panel { + padding-right: 14px; + } + } + + .sidebar { + border-left: 1px solid lighten($color1, 10%); + width: 180px; + flex: 0 0 auto; + } + + .panel { + .panel-header { + background: lighten($color1, 10%); + padding: 7px 14px; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + } + + .panel-body { + padding: 14px; + } + + .panel-list { + ul { + list-style: none; + margin: 0; + + li { + margin: 0; + font-family: inherit; + font-size: 13px; + line-height: 18px; + + a { + display: block; + padding: 7px 14px; + color: rgba($color5, 0.7); + text-decoration: none; + transition: all 200ms linear; + + i.fa { + margin-right: 5px; + } + + &:hover { + color: $color5; + background-color: darken($color1, 5%); + transition: all 100ms linear; + } + + &.selected { + color: $color5; + background-color: $color4; + + &:hover { + background-color: lighten($color4, 5%); + } + } + } + } + } + } + } +} diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss index 748bb82241a155fb15ed1ee2659e7f658c51e4d0..7c48c91f39df7fb59a4c2a6adff39bbd7673a0af 100644 --- a/app/assets/stylesheets/accounts.scss +++ b/app/assets/stylesheets/accounts.scss @@ -1,10 +1,10 @@ .card { - background: #282c37; + background: $color1; background-size: cover; padding: 60px 0; padding-bottom: 0; border-radius: 4px 4px 0 0; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba($color8, 0.2); overflow: hidden; position: relative; @@ -14,7 +14,7 @@ } &:after { - background: rgba(0, 0, 0, 0.5); + background: rgba($color8, 0.5); display: block; content: ""; position: absolute; @@ -29,7 +29,7 @@ display: block; font-size: 20px; line-height: 18px * 1.5; - color: #fff; + color: $color5; font-weight: 500; text-align: center; position: relative; @@ -38,7 +38,7 @@ small { display: block; font-size: 14px; - color: #2b90d9; + color: $color4; font-weight: 400; } } @@ -81,10 +81,10 @@ .counter { width: 80px; - color: #9baec8; + color: $color3; padding: 0 10px; margin-bottom: 10px; - border-right: 1px solid #9baec8; + border-right: 1px solid $color3; cursor: default; position: relative; @@ -99,14 +99,14 @@ bottom: -10px; left: 0; width: 100%; - border-bottom: 4px solid #9baec8; + border-bottom: 4px solid $color3; opacity: 0.5; transition: all 0.8s ease; } &.active { &:after { - border-bottom: 4px solid #2b90d9; + border-bottom: 4px solid $color4; opacity: 1; } } @@ -133,7 +133,7 @@ .counter-number { font-weight: 500; font-size: 18px; - color: #fff; + color: $color5; } } @@ -142,7 +142,7 @@ font-size: 14px; line-height: 18px; padding: 5px 10px; - color: #d9e1e8; + color: $color2; order: 1; } @@ -173,7 +173,7 @@ a, .current, .next_page, .previous_page, .gap { font-size: 14px; - color: #fff; + color: $color5; font-weight: 500; display: inline-block; padding: 6px 10px; @@ -181,9 +181,9 @@ } .current { - background: #fff; + background: $color5; border-radius: 100px; - color: #282c37; + color: $color1; cursor: default; } @@ -193,7 +193,7 @@ .previous_page, .next_page { text-transform: uppercase; - color: #d9e1e8; + color: $color2; } .previous_page { @@ -218,7 +218,7 @@ .disabled { cursor: default; - color: lighten(#282c37, 10%); + color: lighten($color1, 10%); } @media screen and (max-width: 360px) { @@ -236,8 +236,8 @@ .accounts-grid { clear: both; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); - background: #fff; + box-shadow: 0 0 15px rgba($color8, 0.2); + background: $color5; border-radius: 0 0 4px 4px; padding: 20px 10px; padding-bottom: 10px; @@ -252,9 +252,9 @@ box-sizing: border-box; width: 335px; float: left; - border: 1px solid #d9e1e8; + border: 1px solid $color2; border-radius: 4px; - color: #282c37; + color: $color1; height: 160px; margin-bottom: 10px; @@ -265,7 +265,7 @@ .account-grid-card__header { overflow: hidden; padding: 10px; - border-bottom: 1px solid #d9e1e8; + border-bottom: 1px solid $color2; } .avatar { @@ -287,7 +287,7 @@ a { display: block; - color: #282c37; + color: $color1; text-decoration: none; &:hover { @@ -304,19 +304,19 @@ } .username { - color: #2b90d9; + color: $color4; } .note { padding: 10px; padding-top: 15px; - color: #9baec8; + color: $color3; } } } .nothing-here { - color: #9baec8; + color: $color3; font-size: 14px; font-weight: 500; text-align: center; @@ -327,10 +327,10 @@ .account-card { padding: 14px 10px; - background: #fff; + background: $color5; border-radius: 4px; text-align: left; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba($color8, 0.2); .detailed-status__display-name { display: block; @@ -363,12 +363,12 @@ strong { font-weight: 500; - color: #282c37; + color: $color1; } span { font-size: 14px; - color: #9baec8; + color: $color3; } } @@ -383,6 +383,6 @@ .account__header__content { font-size: 14px; - color: #282c37; + color: $color1; } } diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss index 6e4234d13dc8d49cccf7d6cc6f660e5cfe7299b6..8d01ac4c4eec239520c764cc1d83ad11806d7bde 100644 --- a/app/assets/stylesheets/admin.scss +++ b/app/assets/stylesheets/admin.scss @@ -2,7 +2,7 @@ width: 100%; height: 100%; position: fixed; - background: #1a1c23; + background: darken($color1, 2%); overflow-y: scroll; .sidebar { @@ -10,7 +10,7 @@ position: fixed; left: 0; height: 100%; - background: #282c37; + background: $color1; .logo { display: block; @@ -25,7 +25,7 @@ a { display: block; padding: 15px 25px; - color: rgba(255, 255, 255, 0.7); + color: rgba($color5, 0.7); text-decoration: none; transition: all 200ms linear; @@ -34,17 +34,17 @@ } &:hover { - color: #fff; - background-color: darken(#282c37, 5%); + color: $color5; + background-color: darken($color1, 5%); transition: all 100ms linear; } &.selected { - color: #fff; - background-color: #2b90d9; + color: $color5; + background-color: $color4; &:hover { - background-color: lighten(#2b90d9, 5%); + background-color: lighten($color4, 5%); } } } @@ -84,21 +84,21 @@ a { display: inline-block; - color: rgba(255, 255, 255, 0.7); + color: rgba($color5, 0.7); text-decoration: none; text-transform: uppercase; font-size: 12px; font-weight: 500; - border-bottom: 2px solid #282c37; + border-bottom: 2px solid $color1; &:hover { - color: #fff; - border-bottom: 2px solid lighten(#282c37, 5%); + color: $color5; + border-bottom: 2px solid lighten($color1, 5%); } &.selected { - color: #2b90d9; - border-bottom: 2px solid #2b90d9; + color: $color4; + border-bottom: 2px solid $color4; } } } diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e4c550b818553ef367b329ebc1f28a50d3f4f7d4..649a0148b8db81d552187207a30fd09d8e04bbef 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,6 +1,8 @@ +@import 'variables'; @import url(https://fonts.googleapis.com/css?family=Roboto:400,500,400italic); @import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500); -@import "font-awesome"; +@import url(https://fonts.googleapis.com/css?family=Montserrat); +@import 'font-awesome'; /* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 @@ -63,31 +65,31 @@ table { } ::-webkit-scrollbar-thumb { - background: #42495b; - border: 0px none #ffffff; + background: lighten($color1, 4%); + border: 0px none $color5; border-radius: 50px; } ::-webkit-scrollbar-thumb:hover { - background: #525a70; + background: lighten($color1, 6%); } ::-webkit-scrollbar-thumb:active { - background: #42495b; + background: lighten($color1, 4%); } ::-webkit-scrollbar-track { - border: 0px none #ffffff; + border: 0px none $color5; border-radius: 0; - background: rgba(0, 0, 0, 0.1); + background: rgba($color8, 0.1); } ::-webkit-scrollbar-track:hover { - background: #282c37; + background: $color1; } ::-webkit-scrollbar-track:active { - background: #282c37; + background: $color1; } ::-webkit-scrollbar-corner { @@ -96,13 +98,13 @@ table { body { font-family: 'Roboto', sans-serif; - background: #282c37 image-url('background-photo.jpeg'); + background: $color1 image-url('background-photo.jpeg'); background-size: cover; background-attachment: fixed; font-size: 13px; line-height: 18px; font-weight: 400; - color: #fff; + color: $color5; padding-bottom: 140px; text-rendering: optimizelegibility; font-feature-settings: "kern"; @@ -164,7 +166,7 @@ body { h1 { display: block; text-align: center; - color: #fff; + color: $color5; font-size: 48px; font-weight: 500; @@ -215,12 +217,10 @@ body { text-align: center; margin-top: 30px; font-size: 12px; - color: darken(#d9e1e8, 25%); + color: darken($color2, 25%); .domain { - //font-size: 12px; font-weight: 500; - //font-family: 'Roboto Mono', monospace; a { color: inherit; diff --git a/app/assets/stylesheets/boost.scss b/app/assets/stylesheets/boost.scss new file mode 100644 index 0000000000000000000000000000000000000000..a2e6421f82f7f5dd232486f2a94046430aec7bb5 --- /dev/null +++ b/app/assets/stylesheets/boost.scss @@ -0,0 +1,7 @@ +@function url-friendly-colour($colour) { + @return '%23' + str-slice('#{$colour}', 2, -1) +} + +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='#{url-friendly-colour(lighten($color1, 26%))}' 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='#{url-friendly-colour($color4)}' stroke-width='0'/></svg>"); +} diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index acfa85c6bdcd03c322ac45df5a01ea45f7de2340..6014da5b691d241b789c7ea35d4313209ae31d3a 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -1,12 +1,12 @@ .button { - background-color: #2b90d9; - font-family: 'Roboto'; + background-color: darken($color4, 3%); + font-family: inherit; display: inline-block; position: relative; box-sizing: border-box; text-align: center; border: 10px none; - color: #fff; + color: $color5; font-size: 14px; font-weight: 500; letter-spacing: 0; @@ -19,56 +19,69 @@ text-decoration: none; &:hover { - background-color: #489fde; + background-color: lighten($color4, 7%); } &:disabled { - background-color: #9baec8; + background-color: $color3; cursor: default; } &.button-secondary { - background-color: #282c37; + background-color: $color1; &:hover { - background-color: #282c37; + background-color: $color1; } &:disabled { - background-color: #9baec8; + background-color: $color3; } } } .icon-button { - color: #616b86; + color: lighten($color1, 26%); border: none; background: transparent; cursor: pointer; &:hover { - color: #717b98; + color: lighten($color1, 33%); } &.disabled { - color: #454b5e; + color: lighten($color1, 13%); cursor: default; } &.active { - color: #2b90d9; + color: $color4; + } +} + +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; +} + +.ellipsis { + &:after { + content: "…"; } } .lightbox .icon-button { - color: #282c37; + color: $color1; } .compose-form__textarea, .follow-form__input { - background: #fff; + background: $color5; &:disabled { - background: #d9e1e8; + background: $color2; } } @@ -107,7 +120,7 @@ } a { - color: #d9e1e8; + color: $color2; text-decoration: none; &:hover { @@ -139,11 +152,11 @@ } .reply-indicator__content { - color: #282c37; + color: $color1; font-size: 14px; a { - color: #535b72; + color: lighten($color1, 20%); } } @@ -183,13 +196,13 @@ } } -.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name { +.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: #fff; + color: $color5; } &.muted { @@ -214,7 +227,7 @@ } .detailed-status__display-name { - color: #d9e1e8; + color: $color2; line-height: 24px; strong, span { @@ -223,17 +236,17 @@ strong { font-size: 16px; - color: #fff; + color: $color5; } } .muted { .status__content p, .status__content a { - color: #616b86; + color: lighten($color1, 26%); } .status__display-name strong { - color: #616b86; + color: lighten($color1, 26%); } .status__avatar { @@ -246,7 +259,7 @@ text-decoration: none; &:hover { - color: #fff; + color: $color5; text-decoration: underline; } } @@ -282,17 +295,17 @@ height: 0; border-style: solid; border-width: 0 4.5px 7.8px 4.5px; - border-color: transparent transparent #d9e1e8 transparent; + border-color: transparent transparent $color2 transparent; top: -7px; left: 8px; } ul { list-style: none; - background: #d9e1e8; + background: $color2; padding: 4px 0; border-radius: 4px; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 15px rgba($color8, 0.4); min-width: 100px; } @@ -302,12 +315,12 @@ padding: 6px 16px; width: 100px; text-decoration: none; - background: #d9e1e8; - color: #282c37; + background: $color2; + color: $color1; &:hover { - background: #2b90d9; - color: #d9e1e8; + background: $color4; + color: $color2; } } } @@ -315,7 +328,7 @@ .static-content { padding: 10px; padding-top: 20px; - color: #616b86; + color: lighten($color1, 26%); h1 { font-size: 16px; @@ -331,11 +344,15 @@ } .columns-area { - margin: 10px; - margin-left: 0; flex-direction: row; } +@media screen and (min-width: 360px) { + .columns-area { + margin: 10px; + } +} + .column { width: 330px; position: relative; @@ -345,12 +362,43 @@ width: 280px; } +.drawer__inner { + background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65)); +} + +.drawer__header { + flex: 0 0 auto; + font-size: 16px; + background: lighten($color1, 8%); + margin-bottom: 10px; + display: flex; + flex-direction: row; + + a { + transition: all 100ms ease-in; + + &:hover { + background: lighten($color1, 3%); + transition: all 200ms ease-out; + } + } +} + .column, .drawer { - margin-left: 10px; + margin-left: 5px; + margin-right: 5px; flex: 0 0 auto; overflow: hidden; } +.column:first-child, .drawer:first-child { + margin-left: 0; +} + +.column:last-child, .drawer:last-child { + margin-right: 0; +} + @media screen and (max-width: 1024px) { .column, .drawer { width: 100%; @@ -359,7 +407,6 @@ } .columns-area { - margin: 10px; flex-direction: column; } } @@ -368,6 +415,13 @@ display: flex; } +@media screen and (min-width: 360px) { + .tabs-bar { + margin: 10px; + margin-bottom: 0; + } +} + @media screen and (min-width: 1025px) { .tabs-bar { display: none; @@ -383,22 +437,22 @@ top: 100%; width: 100%; z-index: 99; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 15px rgba($color8, 0.4); } .react-autosuggest__section-title { - background: #9baec8; + background: $color3; padding: 4px 10px; font-weight: 500; cursor: default; - color: #282c37; + color: $color1; text-transform: uppercase; font-size: 11px; } .react-autosuggest__suggestions-list { - background: #d9e1e8; - color: #282c37; + background: $color2; + color: $color1; font-size: 14px; } @@ -408,8 +462,8 @@ } .react-autosuggest__suggestion--focused { - background: #2b90d9; - color: #fff; + background: $color4; + color: $color5; } .scrollable { @@ -417,6 +471,10 @@ overflow-x: hidden; flex: 1 1 auto; -webkit-overflow-scrolling: touch; + + &.optionally-scrollable { + overflow-y: auto; + } } .column-back-button { @@ -433,7 +491,7 @@ border: 0; padding: 0; user-select: none; - -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: rgba($color8, 0); -webkit-tap-highlight-color: transparent; } @@ -459,20 +517,20 @@ height: 24px; padding: 0; border-radius: 30px; - background-color: #282c37; + background-color: $color1; transition: all 0.2s ease; } .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { - background-color: darken(#282c37, 10%); + background-color: darken($color1, 10%); } .react-toggle--checked .react-toggle-track { - background-color: #2b90d9; + background-color: $color4; } .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { - background-color: lighten(#2b90d9, 10%); + background-color: lighten($color4, 10%); } .react-toggle-track-check { @@ -519,59 +577,62 @@ left: 1px; width: 22px; height: 22px; - border: 1px solid #282c37; + border: 1px solid $color1; border-radius: 50%; - background-color: #FAFAFA; + background-color: darken($color5, 2%); box-sizing: border-box; transition: all 0.25s ease; } .react-toggle--checked .react-toggle-thumb { left: 27px; - border-color: #2b90d9; + border-color: $color4; } .column-link { - background: #373b4a; + background: lighten($color1, 6%); &:hover { - background: lighten(#373b4a, 5%); + background: lighten($color1, 11%); } } -.autosuggest-textarea { +.autosuggest-textarea, .spoiler-input { position: relative; } -.autosuggest-textarea__textarea { +.autosuggest-textarea__textarea, .spoiler-input__input { display: block; box-sizing: border-box; width: 100%; - height: 100px; resize: none; - color: #282c37; + margin: 0; + color: $color1; padding: 7px; - font-family: 'Roboto'; + font-family: inherit; font-size: 14px; - margin: 0; resize: vertical; border: 3px dashed transparent; transition: border-color 0.3s ease; &.file-drop { - border-color: #aaa; + border-color: darken($color5, 33%); } } +.autosuggest-textarea__textarea { + height: 100px; +} + .autosuggest-textarea__suggestions { position: absolute; top: 100%; width: 100%; z-index: 99; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); - background: #d9e1e8; - color: #282c37; + box-shadow: 0 0 15px rgba($color8, 0.4); + background: $color2; + color: $color1; font-size: 14px; } @@ -580,21 +641,69 @@ cursor: pointer; &:hover { - background: darken(#d9e1e8, 10%); + background: darken($color2, 10%); } &.selected { - background: #2b90d9; - color: #fff; + background: $color4; + color: $color5; } } -.getting-started__illustration { - width: 330px; - height: 235px; - background: image-url('mastodon-getting-started.png') no-repeat 0 0; - position: absolute; - pointer-events: none; - bottom: 0; - left: 0; +.getting-started { + box-sizing: border-box; + overflow-y: auto; + padding-bottom: 235px; + background: image-url('mastodon-getting-started.png') no-repeat 0 100% local; + height: 100%; + + p { + color: $color2; + } +} + +.dropdown__content.dropdown__left { + transform: translateX(-108px); + + &::before { + right: 8px !important; + left: initial !important; + } +} + +.setting-text { + color: $color3; + background: transparent; + border: none; + border-bottom: 2px solid $color3; + + &:focus, &:active { + color: $color5; + border-bottom-color: $color4; + } +} + +@import 'boost'; + +button i.fa-retweet { + height: 19px; + width: 22px; + background-position: 0 0; + transition: background-position 0.9s steps(10); + transition-duration: 0s; + + &::before { + display: none !important; + } +} + +button.active i.fa-retweet { + transition-duration: 0.9s; + background-position: 0 100%; +} + +.status-card { + &:hover { + background: lighten($color1, 6%); + } } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index e6d2e85a2f2c1916e38c32755b65996167ad16f1..36539651108e0ced788130dd183cffa287f67c22 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -16,7 +16,7 @@ code { .hint { display: block; - color: rgba(255, 255, 255, 0.8); + color: rgba($color5, 0.8); font-size: 12px; } @@ -26,9 +26,9 @@ code { display: flex; label { - font-family: 'Roboto'; + font-family: inherit; font-size: 16px; - color: #fff; + color: $color5; width: 100px; display: block; flex: 0 0 auto; @@ -48,7 +48,7 @@ code { margin-bottom: 5px; label { - font-family: 'Roboto'; + font-family: inherit; font-size: 14px; color: white; display: block; @@ -75,42 +75,42 @@ code { background: transparent; box-sizing: border-box; border: 0; - border-bottom: 2px solid #9baec8; + border-bottom: 2px solid $color3; border-radius: 2px 2px 0 0; padding: 7px 4px; font-size: 16px; - color: #fff; + color: $color5; display: block; width: 100%; outline: 0; - font-family: 'Roboto'; + font-family: inherit; &:invalid { box-shadow: none; } &:focus:invalid { - border-bottom-color: #df405a; + border-bottom-color: $color6; } &:required:valid { - border-bottom-color: #79bd9a; + border-bottom-color: $color7; } &:active, &:focus { - border-bottom-color: #2b90d9; - background: rgba(0, 0, 0, 0.1); + border-bottom-color: $color4; + background: rgba($color8, 0.1); } } .input.field_with_errors { input[type=text], input[type=email], input[type=password] { - border-bottom-color: #df405a; + border-bottom-color: $color6; } .error { font-weight: 500; - color: #df405a; + color: $color6; } } @@ -123,8 +123,8 @@ code { width: 100%; border: 0; border-radius: 4px; - background: #2b90d9; - color: #fff; + background: $color4; + color: $color5; font-size: 18px; padding: 10px; text-transform: uppercase; @@ -134,36 +134,36 @@ code { margin-bottom: 10px; &:hover { - background-color: lighten(#2b90d9, 5%); + background-color: lighten($color4, 5%); } &:active, &:focus { position: relative; top: 1px; - background-color: darken(#2b90d9, 5%); + background-color: darken($color4, 5%); } &.negative { - background: #df405a; + background: $color6; &:hover { - background-color: lighten(#df405a, 5%); + background-color: lighten($color6, 5%); } &:active, &:focus { - background-color: darken(#df405a, 5%); + background-color: darken($color6, 5%); } } } } .flash-message { - background: #282c37; - color: #9baec8; + background: $color1; + color: $color3; border-radius: 4px; padding: 15px 10px; margin-bottom: 30px; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 5px rgba($color8, 0.2); text-align: center; strong { @@ -188,7 +188,7 @@ code { .oauth-prompt, .follow-prompt { margin-bottom: 30px; text-align: center; - color: #9baec8; + color: $color3; h2 { font-size: 16px; @@ -196,7 +196,7 @@ code { } strong { - color: #d9e1e8; + color: $color2; font-weight: 500; } } diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss index 7624bbdc80b8d68041f0673db8e8f8d2d635c040..2d3cb1436682bd7c57849bfca32ebf03bc945562 100644 --- a/app/assets/stylesheets/stream_entries.scss +++ b/app/assets/stylesheets/stream_entries.scss @@ -1,12 +1,12 @@ .activity-stream { clear: both; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba($color8, 0.2); .entry { - background: lighten(#d9e1e8, 8%); + background: lighten($color2, 8%); &, .detailed-status.light { - border-bottom: 1px solid #d9e1e8; + border-bottom: 1px solid $color2; } &:last-child { @@ -43,7 +43,7 @@ font-size: 14px; .status__relative-time { - color: #9baec8; + color: $color3; } } } @@ -52,7 +52,7 @@ display: block; max-width: 100%; padding-right: 25px; - color: #282c37; + color: $color1; } .status__avatar { @@ -82,20 +82,20 @@ strong { font-weight: 500; - color: #282c37; + color: $color1; } span { font-size: 14px; - color: #9baec8; + color: $color3; } } .status__content { - color: #282c37; + color: $color1; a { - color: #2b90d9; + color: $color4; } } @@ -111,7 +111,7 @@ .detailed-status.light { padding: 14px; - background: #fff; + background: $color5; cursor: default; .detailed-status__display-name { @@ -133,12 +133,12 @@ strong { font-weight: 500; - color: #282c37; + color: $color1; } span { font-size: 14px; - color: #9baec8; + color: $color3; } } } @@ -154,16 +154,16 @@ } .status__content { - color: #282c37; + color: $color1; a { - color: #2b90d9; + color: $color4; } } .detailed-status__meta { margin-top: 15px; - color: #9baec8; + color: $color3; font-size: 14px; line-height: 18px; @@ -248,12 +248,13 @@ transform: translate(-50%, -50%); padding: 5px; border-radius: 100px; - color: rgba(255, 255, 255, 0.8); + color: rgba($color5, 0.8); + z-index: 1; } } .media-spoiler { - background: #9baec8; + background: $color3; width: 100%; height: 100%; cursor: pointer; @@ -263,9 +264,10 @@ flex-direction: column; text-align: center; transition: all 100ms linear; + z-index: 2; &:hover { - background: darken(#9baec8, 5%); + background: darken($color3, 5%); } span { @@ -287,7 +289,7 @@ padding-left: (48px + 14px*2); padding-bottom: 0; margin-bottom: -4px; - color: #9baec8; + color: $color3; font-size: 14px; position: relative; @@ -297,7 +299,7 @@ } .status__display-name.muted strong { - color: #9baec8; + color: $color3; } } } diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss index a378707867291eb745c8cc109d57fde54f6cc91e..ad8050580b75abc5709559f194dcfd263d5b0184 100644 --- a/app/assets/stylesheets/tables.scss +++ b/app/assets/stylesheets/tables.scss @@ -9,13 +9,13 @@ padding: 8px; line-height: 18px; vertical-align: top; - border-top: 1px solid #282c37; + border-top: 1px solid $color1; text-align: left; } & > thead > tr > th { vertical-align: bottom; - border-bottom: 2px solid #282c37; + border-bottom: 2px solid $color1; border-top: 0; font-weight: 500; } @@ -25,17 +25,21 @@ } & > tbody > tr:nth-child(odd) > td, & > tbody > tr:nth-child(odd) > th { - background: lighten(#1a1c23, 2%); + background: $color1; } a { - color: #2b90d9; + color: $color4; text-decoration: underline; &:hover { text-decoration: none; } } + + strong { + font-weight: 500; + } } samp { @@ -47,11 +51,11 @@ a.table-action-link { display: inline-block; margin-right: 5px; padding: 0 10px; - color: rgba(255, 255, 255, 0.7); + color: rgba($color5, 0.7); font-weight: 500; &:hover { - color: #fff; + color: $color5; } i.fa { diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss new file mode 100644 index 0000000000000000000000000000000000000000..de4157af8f25e3f85a35b409d4842d7efd95d37a --- /dev/null +++ b/app/assets/stylesheets/variables.scss @@ -0,0 +1,8 @@ +$color1: #282c37; // darkest +$color2: #d9e1e8; // lightest +$color3: #9baec8; // lighter +$color4: #2b90d9; // vibrant +$color5: #fff; // white +$color6: #df405a; // error red +$color7: #79bd9a; // succ green +$color8: #000; // black diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 7df58444f1a78b9ffcccb8ea8b08ec1a704296f0..491036db2831b4c807024b8e56cbed6cf6c2b2ef 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -4,11 +4,21 @@ class AboutController < ApplicationController before_action :set_body_classes def index + @description = Setting.site_description end - def terms + def more + @description = Setting.site_description + @extended_description = Setting.site_extended_description + @contact_account = Account.find_local(Setting.site_contact_username) + @contact_email = Setting.site_contact_email + @user_count = Rails.cache.fetch('user_count') { User.count } + @status_count = Rails.cache.fetch('local_status_count') { Status.local.count } + @domain_count = Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) } end + def terms; end + private def set_body_classes diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..af0be88230766daff185935e8bab6fdc59d136af --- /dev/null +++ b/app/controllers/admin/settings_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Admin::SettingsController < ApplicationController + before_action :require_admin! + + layout 'admin' + + def index + @settings = Setting.all_as_records + end + + def update + @setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id]) + + if @setting.value != params[:setting][:value] + @setting.value = params[:setting][:value] + @setting.save + end + + respond_to do |format| + format.html { redirect_to admin_settings_path } + format.json { respond_with_bip(@setting) } + end + end +end diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 2360061fff8e7d6ed800b429ea01015a47b836fd..379e910e63b839e7c1f4d28d890af47fe77d427f 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::OembedController < ApiController +class Api::OEmbedController < ApiController respond_to :json def show diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 05ff806c582f6a4128897ec43e7cb90c5d6f8643..d97010c0e54d4a3699a4d3c9097d6001605450aa 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -16,13 +16,13 @@ class Api::V1::AccountsController < ApiController end def following - results = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = Follow.where(account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.target_account_id] } set_account_counters_maps(@accounts) - next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -31,13 +31,13 @@ class Api::V1::AccountsController < ApiController end def followers - results = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = Follow.where(target_account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.account_id] } set_account_counters_maps(@accounts) - next_path = followers_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = followers_api_v1_account_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -46,13 +46,13 @@ class Api::V1::AccountsController < ApiController end def statuses - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) set_maps(@statuses) set_counters_maps(@statuses) - next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -66,7 +66,12 @@ class Api::V1::AccountsController < ApiController def block BlockService.new.call(current_user.account, @account) - set_relationship + + @following = { @account.id => false } + @followed_by = { @account.id => false } + @blocking = { @account.id => true } + @requested = { @account.id => false } + render action: :relationship end @@ -93,10 +98,9 @@ class Api::V1::AccountsController < ApiController end def search - limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT - @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true') + @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true') - set_account_counters_maps(@accounts) + set_account_counters_maps(@accounts) unless @accounts.nil? render action: :index end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 1b33770f4e32a08de49eb06d3f43115d5786802a..ca9dd0b7eea90eb25285f1154332915f58558542 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -4,6 +4,6 @@ class Api::V1::AppsController < ApiController respond_to :json def create - @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes)) + @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website]) end end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 8629242ab1a2e1d4882a22446000c8abd2ad66a4..b9816e052b365575a10e5a62a75f8f1bbf2489cc 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -7,13 +7,13 @@ class Api::V1::BlocksController < ApiController respond_to :json def index - results = Block.where(account: current_account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = Block.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.target_account_id] } set_account_counters_maps(@accounts) - next_path = api_v1_blocks_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = api_v1_blocks_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = api_v1_blocks_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index a71592acdf322d5a261e2550d7f52985ff2908a0..ef0a4854aba17a86c3c07787f277f98beeec5ef4 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -7,13 +7,13 @@ class Api::V1::FavouritesController < ApiController respond_to :json def index - results = Favourite.where(account: current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + results = Favourite.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status) set_maps(@statuses) set_counters_maps(@statuses) - next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index c8f162cb02e2329c6f4184cb00a11ed43b01da56..877356a75e06ccb431d8637a114c4e2413a63d69 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -6,8 +6,10 @@ class Api::V1::NotificationsController < ApiController respond_to :json + DEFAULT_NOTIFICATIONS_LIMIT = 15 + def index - @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id]) @notifications = cache_collection(@notifications, Notification) statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status) @@ -15,9 +17,18 @@ class Api::V1::NotificationsController < ApiController set_counters_maps(statuses) set_account_counters_maps(@notifications.map(&:from_account)) - next_path = api_v1_notifications_url(max_id: @notifications.last.id) if @notifications.size == 20 + next_path = api_v1_notifications_url(max_id: @notifications.last.id) if @notifications.size == limit_param(DEFAULT_NOTIFICATIONS_LIMIT) prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty? set_pagination_headers(next_path, prev_path) end + + def show + @notification = Notification.where(account: current_account).find(params[:id]) + end + + def clear + Notification.where(account: current_account).delete_all + render_empty + end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index f7b4ed6100a29124ea1c6e9cb86a9c003bcc6535..4b095a570ae4914665dd87a94743180224774287 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -3,8 +3,8 @@ class Api::V1::StatusesController < ApiController before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] - before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by] - before_action :set_status, only: [:show, :context, :reblogged_by, :favourited_by] + before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by] + before_action :set_status, only: [:show, :context, :card, :reblogged_by, :favourited_by] respond_to :json @@ -14,21 +14,26 @@ class Api::V1::StatusesController < ApiController end def context - @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account)) + @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account)) statuses = [@status] + @context[:ancestors] + @context[:descendants] set_maps(statuses) set_counters_maps(statuses) end + def card + @card = PreviewCard.find_by(status: @status) + render_empty if @card.nil? + end + def reblogged_by - results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = @status.reblogs.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |r| accounts[r.account_id] } set_account_counters_maps(@accounts) - next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -37,13 +42,13 @@ class Api::V1::StatusesController < ApiController end def favourited_by - results = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) + results = @status.favourites.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.account_id] } set_account_counters_maps(@accounts) - next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT + next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty? set_pagination_headers(next_path, prev_path) @@ -52,7 +57,12 @@ class Api::V1::StatusesController < ApiController end def create - @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility]) + @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], + sensitive: params[:sensitive], + spoiler_text: params[:spoiler_text], + visibility: params[:visibility], + application: doorkeeper_token.application) + render action: :show end diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index 9727797e593d7fa2bd6c85188031a6360cfcf76b..5042550db8b573c5561074fc04bc6524db7c47c1 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -7,14 +7,14 @@ class Api::V1::TimelinesController < ApiController respond_to :json def home - @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = Feed.new(:home, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -23,14 +23,14 @@ class Api::V1::TimelinesController < ApiController end def mentions - @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = Feed.new(:mentions, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -39,14 +39,14 @@ class Api::V1::TimelinesController < ApiController end def public - @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) @@ -56,14 +56,14 @@ class Api::V1::TimelinesController < ApiController def tag @tag = Tag.find_by(name: params[:id].downcase) - @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]) + @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) - next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT + next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty? set_pagination_headers(next_path, prev_path) diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..c00e016a4acd7b4d97d24ab50ece5802219b7588 --- /dev/null +++ b/app/controllers/api/web/settings_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::Web::SettingsController < ApiController + respond_to :json + + before_action :require_user! + + def update + setting = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user) + setting.data = params[:data] + setting.save! + + render_empty + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 8f1c8ac8adbbd16b1a8023f63134d36b92d8bd0c..5d2bd9a225e3d9b4acf66e3d105b9b53b94113c5 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -62,6 +62,11 @@ class ApiController < ApplicationController response.headers['Link'] = LinkHeader.new(links) end + def limit_param(default_limit) + return default_limit unless params[:limit] + [params[:limit].to_i.abs, default_limit * 2].min + end + def current_resource_owner @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end @@ -89,19 +94,19 @@ class ApiController < ApplicationController return end - status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact.uniq + status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq @reblogs_map = Status.reblogs_map(status_ids, current_account) @favourites_map = Status.favourites_map(status_ids, current_account) end def set_counters_maps(statuses) # rubocop:disable Style/AccessorMethodName - status_ids = statuses.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq + status_ids = statuses.compact.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq @favourites_counts_map = Favourite.select('status_id, COUNT(id) AS favourites_count').group('status_id').where(status_id: status_ids).map { |f| [f.status_id, f.favourites_count] }.to_h @reblogs_counts_map = Status.select('statuses.id, COUNT(reblogs.id) AS reblogs_count').joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.id = reblogs.reblog_of_id').where(id: status_ids).group('statuses.id').map { |r| [r.id, r.reblogs_count] }.to_h end def set_account_counters_maps(accounts) # rubocop:disable Style/AccessorMethodName - account_ids = accounts.map(&:id) + account_ids = accounts.compact.map(&:id).uniq @followers_counts_map = Follow.unscoped.select('target_account_id, COUNT(account_id) AS followers_count').group('target_account_id').where(target_account_id: account_ids).map { |f| [f.target_account_id, f.followers_count] }.to_h @following_counts_map = Follow.unscoped.select('account_id, COUNT(target_account_id) AS following_count').group('account_id').where(account_id: account_ids).map { |f| [f.account_id, f.following_count] }.to_h @statuses_counts_map = Status.unscoped.select('account_id, COUNT(id) AS statuses_count').group('account_id').where(account_id: account_ids).map { |s| [s.account_id, s.statuses_count] }.to_h diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a6b50a297f7c4525a557664dc3e6341e7d23de6..e4b6d0fafed04d900c1a90875ec22c448f3667d4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :set_locale @@ -50,12 +51,21 @@ class ApplicationController < ActionController::Base def not_found respond_to do |format| format.any { head 404 } + format.html { render 'errors/404', layout: 'error' } end end def gone respond_to do |format| format.any { head 410 } + format.html { render 'errors/410', layout: 'error' } + end + end + + def unprocessable_entity + respond_to do |format| + format.any { head 422 } + format.html { render 'errors/422', layout: 'error' } end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 60eb9905a3767c022ecbc4a749eadf7c7cb46fb4..6ce4984bb31ee9e1d3467c2fafea7c7d81796389 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -23,6 +23,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController new_user_session_path end + def after_inactive_sign_up_path_for(_resource) + new_user_session_path + end + def check_single_user_mode redirect_to root_path if Rails.configuration.x.single_user_mode end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index a25fe77dad8c932b6798fa738762864e4bdb0153..814b1f758c76fe4ebf5944a6ef9bbf80a894faac 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -6,6 +6,7 @@ class HomeController < ApplicationController def index @body_classes = 'app-body' @token = find_or_create_access_token.token + @web_settings = Web::Setting.find_by(user: current_user)&.data || {} end private diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 6f1f7ec482f1e1ecacc8b5550483414fa20bdaff..488c4f944f6f7c544067776949872ddcdf1a93dd 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -10,6 +10,7 @@ class MediaController < ApplicationController private def set_media_attachment - @media_attachment = MediaAttachment.where.not(status_id: nil).find(params[:id]) + @media_attachment = MediaAttachment.where.not(status_id: nil).find_by!(shortcode: params[:id]) + raise ActiveRecord::RecordNotFound unless @media_attachment.status.permitted?(current_account) end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 3b6d109a65fbe4fbaf6dbd58ecdc144015930824..f273b5f2145ee1d3f6a4e77f72179fad0e32cc16 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -8,14 +8,18 @@ class Settings::PreferencesController < ApplicationController def show; end def update - current_user.settings(:notification_emails).follow = user_params[:notification_emails][:follow] == '1' - current_user.settings(:notification_emails).follow_request = user_params[:notification_emails][:follow_request] == '1' - current_user.settings(:notification_emails).reblog = user_params[:notification_emails][:reblog] == '1' - current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1' - current_user.settings(:notification_emails).mention = user_params[:notification_emails][:mention] == '1' - - current_user.settings(:interactions).must_be_follower = user_params[:interactions][:must_be_follower] == '1' - current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1' + current_user.settings['notification_emails'] = { + follow: user_params[:notification_emails][:follow] == '1', + follow_request: user_params[:notification_emails][:follow_request] == '1', + reblog: user_params[:notification_emails][:reblog] == '1', + favourite: user_params[:notification_emails][:favourite] == '1', + mention: user_params[:notification_emails][:mention] == '1', + } + + current_user.settings['interactions'] = { + must_be_follower: user_params[:interactions][:must_be_follower] == '1', + must_be_following: user_params[:interactions][:must_be_following] == '1', + } if current_user.update(user_params.except(:notification_emails, :interactions)) redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg') diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 3f60bb0c4c005669112da2563bfbef14ab55fa15..5701b2efae12eec74945e7b73b8624574db136b2 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -46,7 +46,7 @@ class StreamEntriesController < ApplicationController @stream_entry = @account.stream_entries.find(params[:id]) @type = @stream_entry.activity_type.downcase - raise ActiveRecord::RecordNotFound if @stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account))) + raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account)))) end def check_account_suspension diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb index 036a72166e4d553d7802a041ae1f8be5bda67e33..c08d80ea0ed3dc224b574dac1ac4cb51cf7e7dc4 100644 --- a/app/helpers/atom_builder_helper.rb +++ b/app/helpers/atom_builder_helper.rb @@ -41,7 +41,8 @@ module AtomBuilderHelper xml['activity'].send('verb', TagManager::VERBS[verb]) end - def content(xml, content) + def content(xml, content, warning = nil) + xml.summary(warning) unless warning.blank? xml.content({ type: 'html' }, content) unless content.blank? end @@ -153,12 +154,20 @@ module AtomBuilderHelper portable_contact xml, account end + def rich_content(xml, activity) + if activity.is_a?(Status) + content xml, conditionally_formatted(activity), activity.spoiler_text + else + content xml, conditionally_formatted(activity) + end + end + def include_entry(xml, stream_entry) unique_id xml, stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type published_at xml, stream_entry.created_at updated_at xml, stream_entry.updated_at title xml, stream_entry.title - content xml, conditionally_formatted(stream_entry.activity) + rich_content xml, stream_entry.activity verb xml, stream_entry.verb link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom') link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry) diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 6f87c7b72d7e6fc7f47ceb0f811f27c2ac810d61..d3c6b13a65ea0afbeb047e6464f9bd50f98821b0 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -3,8 +3,6 @@ module HomeHelper def default_props { - token: @token, - account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json), locale: I18n.locale, } end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fa569e73ab450d3609dfcf480625d1b595d64acf..aed8770c8a7667bd435b19c6b317bc45ce8f3232 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -14,4 +14,8 @@ module SettingsHelper def human_locale(locale) HUMAN_LOCALES[locale] end + + def hash_to_object(hash) + HashObject.new(hash) + end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index ae2f575b511149edfdccb5475a6c6e45c144ba31..15601a0796bde4fd997591989419ded6ea9f230c 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -15,10 +15,10 @@ module StreamEntriesHelper def entry_classes(status, is_predecessor, is_successor, include_threads) classes = ['entry'] - classes << 'entry-reblog' if status.reblog? - classes << 'entry-predecessor' if is_predecessor - classes << 'entry-successor' if is_successor - classes << 'entry-center' if include_threads + classes << 'entry-reblog u-repost-of h-cite' if status.reblog? + classes << 'entry-predecessor u-in-reply-to h-cite' if is_predecessor + classes << 'entry-successor u-comment h-cite' if is_successor + classes << 'entry-center h-entry' if include_threads classes.join(' ') end diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb new file mode 100644 index 0000000000000000000000000000000000000000..93c0f42f0680df8d686feacd844fc78a45009afb --- /dev/null +++ b/app/lib/application_extension.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ApplicationExtension + extend ActiveSupport::Concern + + included do + validates :website, url: true, unless: 'website.blank?' + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 0056321faf30b1c47fd9e0e205524a4569a93c5d..cdd26e69c62e72349ea93456a474d35c4bac6ada 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -43,12 +43,22 @@ class FeedManager timeline_key = key(:home, into_account.id) from_account.statuses.limit(MAX_ITEMS).each do |status| + next if filter?(:home, status, into_account) redis.zadd(timeline_key, status.id, status.id) end trim(:home, into_account.id) end + def unmerge_from_timeline(from_account, into_account) + timeline_key = key(:home, into_account.id) + + from_account.statuses.select('id').find_each do |status| + redis.zrem(timeline_key, status.id) + redis.zremrangebyscore(timeline_key, status.id, status.id) + end + end + def inline_render(target_account, template, object) rabl_scope = Class.new do include RoutingHelper diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 04386d295a3918ebc8b452a030cab7f4104757ef..ff2a16f1b40c3498153c272b20f942b69995f2e4 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -14,7 +14,7 @@ class Formatter html = status.text html = encode(html) - html = simple_format(html, sanitize: false) + html = simple_format(html, {}, sanitize: false) html = html.gsub(/\n/, '') html = link_urls(html) html = link_mentions(html, status.mentions) @@ -32,6 +32,7 @@ class Formatter html = encode(account.note) html = link_urls(html) + html = link_hashtags(html) html.html_safe # rubocop:disable Rails/OutputSafety end @@ -43,8 +44,8 @@ class Formatter end def link_urls(html) - auto_link(html, link: :urls, html: { rel: 'nofollow noopener', target: '_blank' }) do |text| - truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30) + html.gsub(URI.regexp(%w(http https))) do |match| + link_html(match) end end @@ -63,6 +64,14 @@ class Formatter end end + def link_html(url) + prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s + text = url[prefix.length, 30] + suffix = url[prefix.length + 30..-1] + + "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"ellipsis\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>" + end + def hashtag_html(match) prefix, affix = match.split('#') "#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>" diff --git a/app/lib/hash_object.rb b/app/lib/hash_object.rb new file mode 100644 index 0000000000000000000000000000000000000000..274c020ada520f8ee2d5c395bf7d8076165986f1 --- /dev/null +++ b/app/lib/hash_object.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class HashObject + def initialize(hash) + hash.each do |k, v| + instance_variable_set("@#{k}", v) + self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") }) + end + end +end diff --git a/app/lib/settings/extend.rb b/app/lib/settings/extend.rb new file mode 100644 index 0000000000000000000000000000000000000000..407c3480f0f39a4c47684bc4cdb94f1eaa165ead --- /dev/null +++ b/app/lib/settings/extend.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Settings + module Extend + extend ActiveSupport::Concern + + def settings + ScopedSettings.for_thing(self) + end + end +end diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..82b70d12801b152bd2f81c3ad345bd29f438f0b9 --- /dev/null +++ b/app/lib/settings/scoped_settings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Settings + class ScopedSettings < ::Setting + def self.for_thing(object) + @object = object + self + end + + def self.thing_scoped + unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id) + end + end +end diff --git a/app/lib/status_length_validator.rb b/app/lib/status_length_validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..55135a5980a479bad9f5d40469daa67e4153eb00 --- /dev/null +++ b/app/lib/status_length_validator.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class StatusLengthValidator < ActiveModel::Validator + MAX_CHARS = 500 + + def validate(status) + return unless status.local? && !status.reblog? + status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if [status.text, status.spoiler_text].join.length > MAX_CHARS + end +end diff --git a/app/lib/url_validator.rb b/app/lib/url_validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..4a5c4ef3fffabde357d25edded1fed2118de239f --- /dev/null +++ b/app/lib/url_validator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value) + end + + private + + def compliant?(url) + parsed_url = Addressable::URI.parse(url) + !parsed_url.nil? && %w(http https).include?(parsed_url.scheme) && parsed_url.host + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 5c1f6e7c1cca3cdf229b0b2d338f3ddb48133a84..c2a41c4c638c731de74f2ab64f01876346ae5b8c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -62,8 +62,8 @@ class Account < ApplicationRecord scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers } scope :silenced, -> { where(silenced: true) } scope :suspended, -> { where(suspended: true) } - scope :recent, -> { reorder('id desc') } - scope :alphabetic, -> { order('domain ASC, username ASC') } + scope :recent, -> { reorder(id: :desc) } + scope :alphabetic, -> { order(domain: :asc, username: :asc) } def follow!(other_account) active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) @@ -104,7 +104,7 @@ class Account < ApplicationRecord end def subscribed? - !subscription_expires_at.nil? + !subscription_expires_at.blank? end def favourited?(status) @@ -125,13 +125,10 @@ class Account < ApplicationRecord def save_with_optional_avatar! save! - rescue ActiveRecord::RecordInvalid => invalid - if invalid.record.errors[:avatar_file_size] || invalid[:avatar_content_type] - self.avatar = nil - retry - end - - raise invalid + rescue ActiveRecord::RecordInvalid + self.avatar = nil + self[:avatar_remote_url] = '' + save! end def avatar_remote_url=(url) @@ -159,6 +156,7 @@ class Account < ApplicationRecord end def find_remote!(username, domain) + return if username.blank? where(arel_table[:username].matches(username.gsub(/[%_]/, '\\\\\0'))).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain.gsub(/[%_]/, '\\\\\0'))).take! end @@ -175,19 +173,25 @@ class Account < ApplicationRecord end def following_map(target_account_ids, account_id) - Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h + follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end def followed_by_map(target_account_ids, account_id) - Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h + follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id) end def blocking_map(target_account_ids, account_id) - Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h + follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end def requested_map(target_account_ids, account_id) - FollowRequest.where(target_account_id: target_account_ids).where(account_id: account_id).map { |r| [r.target_account_id, true] }.to_h + follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + end + + private + + def follow_mapping(query, field) + query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping } end end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 9075b90a0207b9c75a1b3d0c212b43c7218f679b..b4606da60dc87c1f8e5596359aaa212641a36dd4 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class DomainBlock < ApplicationRecord + enum severity: [:silence, :suspend] + validates :domain, presence: true, uniqueness: true def self.blocked?(domain) diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 8eef3abf446546f3ab71c3c07ac1ff78e3ac3810..936ad0691b9c519226824aa0f9547a2067a3ef18 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -13,7 +13,7 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account) - FeedManager.instance.merge_into_timeline(target_account, account) + MergeWorker.perform_async(target_account.id, account.id) destroy! end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 2a5d23739ae9dd707d57ed357fd63b47ee36c00a..ecbed03e33402afd18c1207a96fe4ed0af6b306a 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -16,6 +16,7 @@ class MediaAttachment < ApplicationRecord validates :account, presence: true + scope :local, -> { where(remote_url: '') } default_scope { order('id asc') } def local? @@ -38,6 +39,12 @@ class MediaAttachment < ApplicationRecord image? ? 'image' : 'video' end + def to_param + shortcode + end + + before_create :set_shortcode + class << self private @@ -62,4 +69,15 @@ class MediaAttachment < ApplicationRecord end end end + + private + + def set_shortcode + return unless local? + + loop do + self.shortcode = SecureRandom.urlsafe_base64(14) + break if MediaAttachment.find_by(shortcode: shortcode).nil? + end + end end diff --git a/app/models/notification.rb b/app/models/notification.rb index c0b5c45a8c7df387d1310ce8a44a7d41be1a39bd..b7e8c9e71986750416afdb1c67633c1b644f9ccf 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -39,9 +39,9 @@ class Notification < ApplicationRecord def target_status case type when :reblog - activity.reblog + activity&.reblog when :favourite, :mention - activity.status + activity&.status end end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb new file mode 100644 index 0000000000000000000000000000000000000000..e59b05eb877e1b17dca712f2436554e265ff84b3 --- /dev/null +++ b/app/models/preview_card.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PreviewCard < ApplicationRecord + IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + + belongs_to :status + + has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } + + validates :url, presence: true + validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES + validates_attachment_size :image, less_than: 1.megabytes + + def save_with_optional_image! + save! + rescue ActiveRecord::RecordInvalid + self.image = nil + save! + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 0000000000000000000000000000000000000000..3796253d431c1d295557ab1f8a41bacc0c6cb7c6 --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Setting < RailsSettings::Base + source Rails.root.join('config/settings.yml') + namespace Rails.env + + def to_param + var + end + + class << self + def [](key) + return super(key) unless rails_initialized? + + val = Rails.cache.fetch(cache_key(key, @object)) do + db_val = object(key) + + if db_val + default_value = default_settings[key] + + return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash) + db_val.value + else + default_settings[key] + end + end + + val + end + + def all_as_records + vars = thing_scoped + records = vars.map { |r| [r.var, r] }.to_h + + default_settings.each do |key, default_value| + next if records.key?(key) || default_value.is_a?(Hash) + records[key] = Setting.new(var: key, value: default_value) + end + + records + end + + private + + def default_settings + return {} unless RailsSettings::Default.enabled? + RailsSettings::Default.instance + end + end +end diff --git a/app/models/status.rb b/app/models/status.rb index bc595c93b152e925fe4340e089f12171ce6f1f66..651d0dbc9ac83cb848daef3d54ee5b0a67a10619 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true class Status < ApplicationRecord + include ActiveModel::Validations include Paginable include Streamable include Cacheable enum visibility: [:public, :unlisted, :private], _suffix: :visibility + belongs_to :application, class_name: 'Doorkeeper::Application' + belongs_to :account, inverse_of: :statuses belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account' @@ -21,11 +24,12 @@ class Status < ApplicationRecord has_and_belongs_to_many :tags has_one :notification, as: :activity, dependent: :destroy + has_one :preview_card, dependent: :destroy validates :account, presence: true validates :uri, uniqueness: true, unless: 'local?' - validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? } - validates :text, presence: true, if: proc { |s| !s.local? && !s.reblog? } + validates :text, presence: true, unless: 'reblog?' + validates_with StatusLengthValidator validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?' default_scope { order('id desc') } @@ -33,7 +37,7 @@ class Status < ApplicationRecord scope :remote, -> { where.not(uri: nil) } scope :local, -> { where(uri: nil) } - cache_associated :account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account + cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account def local? uri.nil? @@ -171,6 +175,7 @@ class Status < ApplicationRecord before_validation do text.strip! + spoiler_text&.strip! self.reblog = reblog.reblog if reblog? && reblog.reblog? self.in_reply_to_account_id = (thread.account_id == account_id && thread.reply? ? thread.in_reply_to_account_id : thread.account_id) if reply? diff --git a/app/models/user.rb b/app/models/user.rb index d5a52da06fb0c0d79d26ef0b578f26a520de1bcc..71d3ee0b8551132eb5175b5b1c83b2de2db1c285 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class User < ApplicationRecord + include Settings::Extend + devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable belongs_to :account, inverse_of: :user @@ -14,11 +16,6 @@ class User < ApplicationRecord scope :recent, -> { order('id desc') } scope :admins, -> { where(admin: true) } - has_settings do |s| - s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false, follow_request: true } - s.key :interactions, defaults: { must_be_follower: false, must_be_following: false } - end - def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end diff --git a/app/models/web.rb b/app/models/web.rb new file mode 100644 index 0000000000000000000000000000000000000000..58654fd77e1ab6ed3ce6e192a39544c21f3ffc30 --- /dev/null +++ b/app/models/web.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Web + def self.table_name_prefix + 'web_' + end +end diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d601189b28e0e6a644055ad5fdf44b0a5e13fa8 --- /dev/null +++ b/app/models/web/setting.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Web::Setting < ApplicationRecord + belongs_to :user + + validates :user, uniqueness: true +end diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8c6197f2c3407ef6aa7e98f7d4da60803b3095a6 --- /dev/null +++ b/app/services/after_block_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class AfterBlockService < BaseService + def call(account, target_account) + clear_timelines(account, target_account) + clear_notifications(account, target_account) + end + + private + + def clear_timelines(account, target_account) + mentions_key = FeedManager.instance.key(:mentions, account.id) + home_key = FeedManager.instance.key(:home, account.id) + + target_account.statuses.select('id').find_each do |status| + redis.zrem(mentions_key, status.id) + redis.zrem(home_key, status.id) + end + end + + def clear_notifications(account, target_account) + Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all + end + + def redis + Redis.current + end +end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index a8fafe412195c4f4bcaed3ad364ef31e4768337d..9518b1fcf2860e855161a6051f70b7cc8f239530 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true class BlockDomainService < BaseService - def call(domain) - DomainBlock.find_or_create_by!(domain: domain) + def call(domain, severity) + DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity) - Account.where(domain: domain).find_each do |account| - if account.subscribed? - account.subscription(api_subscription_url(account.id)).unsubscribe + if severity == :silence + Account.where(domain: domain).update_all(silenced: true) + else + Account.where(domain: domain).find_each do |account| + account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed? + SuspendAccountService.new.call(account) end - - account.destroy! end end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index b08cf8ca83d562e6d7acf3f738a82f27d4eab2ed..e04b6cc392f427e446b8da1935d1075aeb31779f 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -9,32 +9,7 @@ class BlockService < BaseService block = account.block!(target_account) - clear_timelines(account, target_account) - clear_notifications(account, target_account) - + BlockWorker.perform_async(account.id, target_account.id) NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local? end - - private - - def clear_timelines(account, target_account) - mentions_key = FeedManager.instance.key(:mentions, account.id) - home_key = FeedManager.instance.key(:home, account.id) - - target_account.statuses.select('id').find_each do |status| - redis.zrem(mentions_key, status.id) - redis.zrem(home_key, status.id) - end - end - - def clear_notifications(account, target_account) - Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all - Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all - Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all - Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all - end - - def redis - Redis.current - end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..005e5acea6890187cc61c5bfe55eeef352cdd416 --- /dev/null +++ b/app/services/fetch_link_card_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class FetchLinkCardService < BaseService + def call(status) + # Get first URL + url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first + + return if url.nil? + + response = http_client.get(url) + + return if response.code != 200 || response.mime_type != 'text/html' + + page = Nokogiri::HTML(response.to_s) + card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) + + card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content + card.description = meta_property(page, 'og:description') || meta_property(page, 'description') + card.image = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image') + + return if card.title.blank? + + card.save_with_optional_image! + end + + private + + def http_client + HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow + end + + def meta_property(html, property) + html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value + end +end diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb index f640222b0c20ead64d43909e585f0bb79295673a..b39eafc70061224c1be1ff1eea968d1c3a47a541 100644 --- a/app/services/follow_remote_account_service.rb +++ b/app/services/follow_remote_account_service.rb @@ -14,7 +14,6 @@ class FollowRemoteAccountService < BaseService username, domain = uri.split('@') return Account.find_local(username) if TagManager.instance.local_domain?(domain) - return nil if DomainBlock.blocked?(domain) account = Account.find_remote(username, domain) return account unless account.nil? @@ -36,11 +35,15 @@ class FollowRemoteAccountService < BaseService Rails.logger.debug "Creating new remote account for #{uri}" + domain_block = DomainBlock.find_by(domain: domain) + account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href account.salmon_url = data.link('salmon').href account.url = data.link('http://webfinger.net/rel/profile-page').href account.public_key = magic_key_to_pem(data.link('magic-public-key').href) account.private_key = nil + account.suspended = true if domain_block && domain_block.suspend? + account.silenced = true if domain_block && domain_block.silence? xml = get_feed(account.remote_url) hubs = get_hubs(xml) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 555f01b6d6606bca145b8e7fce2d647cd2322085..87c16a6219180c8c3fcbf281f6d1b8b55228e7ec 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -38,7 +38,7 @@ class FollowService < BaseService NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) end - FeedManager.instance.merge_into_timeline(target_account, source_account) + MergeWorker.perform_async(target_account.id, source_account.id) Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) follow diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 2fb1d39193e4acde683173b5c86ba290d0854da2..1ec36637c06a107b975568a8575d1fffb81f2390 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -6,7 +6,7 @@ class NotifyService < BaseService @activity = activity @notification = Notification.new(account: @recipient, activity: @activity) - return if blocked? + return if blocked? || recipient.user.nil? create_notification send_email if email_enabled? @@ -37,13 +37,13 @@ class NotifyService < BaseService end def blocked? - blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway - blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self - blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban - blocked ||= (@recipient.user.settings(:interactions).must_be_follower && !@notification.from_account.following?(@recipient)) # Options - blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) # Options - blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters + blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway + blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self + blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts + blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban + blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options + blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options + blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters blocked end @@ -58,6 +58,6 @@ class NotifyService < BaseService end def email_enabled? - @recipient.user.settings(:notification_emails).send(@notification.type) + @recipient.user.settings.notification_emails[@notification.type] end end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 55405c0dbed7d8184f611bf1a987504d30a69e0f..979941c84c02a126a723a568c97402fa2cc5eb2f 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -7,14 +7,24 @@ class PostStatusService < BaseService # @param [Status] in_reply_to Optional status to reply to # @param [Hash] options # @option [Boolean] :sensitive + # @option [String] :visibility + # @option [String] :spoiler_text # @option [Enumerable] :media_ids Optional array of media IDs to attach + # @option [Doorkeeper::Application] :application # @return [Status] def call(account, text, in_reply_to = nil, options = {}) - status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility]) + status = account.statuses.create!(text: text, + thread: in_reply_to, + sensitive: options[:sensitive], + spoiler_text: options[:spoiler_text] || '', + visibility: options[:visibility], + application: options[:application]) + attach_media(status, options[:media_ids]) process_mentions_service.call(status) process_hashtags_service.call(status) + LinkCrawlWorker.perform_async(status.id) DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 3860a3504f09ec147a994ef2419c4e4182d84d09..6265341767a5a936b3e797fb04b4620799be0655 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -44,6 +44,8 @@ class ProcessFeedService < BaseService Rails.logger.debug "Creating remote status #{id}" status = status_from_xml(@xml) + return if status.nil? + if verb == :share original_status = status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS)) status.reblog = original_status @@ -59,6 +61,7 @@ class ProcessFeedService < BaseService status.save! NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local? + # LinkCrawlWorker.perform_async(status.reblog? ? status.reblog_of_id : status.id) Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" DistributionWorker.perform_async(status.id) status @@ -100,6 +103,7 @@ class ProcessFeedService < BaseService url: url(entry), account: account, text: content(entry), + spoiler_text: content_warning(entry), created_at: published(entry) ) @@ -177,6 +181,8 @@ class ProcessFeedService < BaseService end def media_from_xml(parent, xml) + return if DomainBlock.find_by(domain: parent.account.domain)&.reject_media? + xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link| next unless link['href'] @@ -218,6 +224,10 @@ class ProcessFeedService < BaseService xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content end + def content_warning(xml = @xml) + xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || '' + end + def published(xml = @xml) xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content end diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index ddcc64aa590d2a4610bd0533ba2d31a508b8840c..617a381590e0f82f34436b671c68db4c05cc3a71 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService def call(status, tags = []) tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local? - tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag| + tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag| status.tags << Tag.where(name: tag).first_or_initialize(name: tag) end diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index 11ec0d2dd1699bf4a4181206f1708cd2300370e0..5f91e312718c6b77874015eaec530cefe971cbdf 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -17,8 +17,6 @@ class ProcessInteractionService < BaseService domain = Addressable::URI.parse(url).host account = Account.find_by(username: username, domain: domain) - return if DomainBlock.blocked?(domain) - if account.nil? account = follow_remote_account_service.call("#{username}@#{domain}") end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index ee42a5df24e69c77891d2ab4cd6100e55b6ea36e..72568e702da4839921fece7631a85c7d5552e724 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -28,7 +28,7 @@ class ProcessMentionsService < BaseService status.mentions.each do |mention| mentioned_account = mention.account - next if status.private_visibility? && !mentioned_account.following?(status.account) + next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?) if mentioned_account.local? NotifyService.new.call(mentioned_account, mention) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 0cb51eecd8472e87e2989da4263f1c647cda4528..4ea0dbf6c42c86834a732251a9048e7754a4d41e 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -6,7 +6,9 @@ class ReblogService < BaseService # @param [Status] reblogged_status Status to be reblogged # @return [Status] def call(account, reblogged_status) - raise Mastodon::NotPermitted if reblogged_status.private_visibility? + reblogged_status = reblogged_status.reblog if reblogged_status.reblog? + + raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account) reblog = account.statuses.create!(reblog: reblogged_status, text: '') diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 836b8fdc594ea84b95cbfde3ac2342ddfeab7227..7aca24d12d379f3a60c6bf26b69c47d0cec372ea 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -53,7 +53,12 @@ class RemoveStatusService < BaseService end def unpush(type, receiver, status) - redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id) + if status.reblog? + redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id) + else + redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id) + end + FeedManager.instance.broadcast(receiver.id, type: 'delete', id: status.id) end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 04a0866135aeb662f176c1c2377588c8cce84612..8528ef62afac0875482f16775a5e2f5b08df2893 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -18,7 +18,6 @@ class SuspendAccountService < BaseService @account.media_attachments.destroy_all @account.stream_entries.destroy_all - @account.mentions.destroy_all @account.notifications.destroy_all @account.favourites.destroy_all @account.active_relationships.destroy_all diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 7973a361116e22fbc4d1b07cf9ee350cf77e34fe..f469793c1afcc0808eefe88f9a34e6f7d61160c7 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -7,21 +7,6 @@ class UnfollowService < BaseService def call(source_account, target_account) follow = source_account.unfollow!(target_account) NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local? - unmerge_from_timeline(target_account, source_account) - end - - private - - def unmerge_from_timeline(from_account, into_account) - timeline_key = FeedManager.instance.key(:home, into_account.id) - - from_account.statuses.select('id').find_each do |status| - redis.zrem(timeline_key, status.id) - redis.zremrangebyscore(timeline_key, status.id, status.id) - end - end - - def redis - Redis.current + UnmergeWorker.perform_async(target_account.id, source_account.id) end end diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb index d961eda399a405457f6fe54909330184a48288fb..ad9c56540da442fd8e79bb484833f28c3e1a66f0 100644 --- a/app/services/update_remote_profile_service.rb +++ b/app/services/update_remote_profile_service.rb @@ -8,9 +8,12 @@ class UpdateRemoteProfileService < BaseService hub_link = xml.at_xpath('./xmlns:link[@rel="hub"]', xmlns: TagManager::XMLNS) unless author_xml.nil? - account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil? - account.note = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil? - account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank? + account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil? + account.note = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil? + + unless account.suspended? || DomainBlock.find_by(domain: account.domain)&.reject_media? + account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank? + end end old_hub_url = account.hub_url diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml index 6dd1822059bcf17a7a4d161166b0526bb5258a1d..88bfe3d610cafec9223287a1fed46a10505af066 100644 --- a/app/views/about/index.html.haml +++ b/app/views/about/index.html.haml @@ -1,3 +1,6 @@ +- content_for :header_tags do + = javascript_include_tag 'application_public' + - content_for :page_title do = Rails.configuration.x.local_domain @@ -5,10 +8,11 @@ %meta{ property: 'og:site_name', content: 'Mastodon' }/ %meta{ property: 'og:type', content: 'website' }/ %meta{ property: 'og:title', content: Rails.configuration.x.local_domain }/ - %meta{ property: 'og:description', content: "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" }/ + %meta{ property: 'og:description', content: @description.blank? ? "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" : strip_tags(@description) }/ %meta{ property: 'og:image', content: asset_url('mastodon_small.jpg') }/ %meta{ property: 'og:image:width', content: '400' }/ %meta{ property: 'og:image:height', content: '400' }/ + %meta{ property: 'twitter:card', content: 'summary' }/ .wrapper %h1 @@ -20,10 +24,14 @@ .screenshot= image_tag 'screenshot.png' + - unless @description.blank? + %p= @description.html_safe + .actions .info + = link_to t('about.learn_more'), about_more_path = link_to t('about.terms'), terms_path - = link_to t('about.source_code'), 'https://github.com/Gargron/mastodon' + = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' = link_to t('about.get_started'), new_user_registration_path, class: 'button webapp-btn' = link_to t('auth.login'), new_user_session_path, class: 'button webapp-btn' diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..2de3bf986f1373793ddff7e0571037b104fab949 --- /dev/null +++ b/app/views/about/more.html.haml @@ -0,0 +1,56 @@ +- content_for :page_title do + #{Rails.configuration.x.local_domain} + +.wrapper.thicc + .sidebar-layout + .main + .panel + %h2= Rails.configuration.x.local_domain + + - unless @description.blank? + %p= @description.html_safe + + .information-board + .section + %span= t 'about.user_count_before' + %strong= number_with_delimiter @user_count + %span= t 'about.user_count_after' + .section + %span= t 'about.status_count_before' + %strong= number_with_delimiter @status_count + %span= t 'about.status_count_after' + .section + %span= t 'about.domain_count_before' + %strong= number_with_delimiter @domain_count + %span= t 'about.domain_count_after' + + - unless @extended_description.blank? + .panel= @extended_description.html_safe + + .sidebar + .panel + .panel-header= t 'about.contact' + .panel-body + - if @contact_account + .owner + .avatar= image_tag @contact_account.avatar.url + .name + = link_to TagManager.instance.url_for(@contact_account) do + %span.display_name.emojify= display_name(@contact_account) + %span.username= "@#{@contact_account.acct}" + + - unless @contact_email.blank? + .contact-email + = t 'about.business_email' + %strong= @contact_email + .panel + .panel-header= t 'about.links' + .panel-list + %ul + - if user_signed_in? + %li= link_to t('about.get_started'), root_path + - else + %li= link_to t('about.get_started'), new_user_registration_path + %li= link_to t('auth.login'), new_user_session_path + %li= link_to t('about.terms'), terms_path + %li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml index dfdb231611d05a8824fb32f8ff3d17d2c6d59d8b..d5418fca5a91f263c0a60d741a10cfcd2cf2bc50 100644 --- a/app/views/accounts/_grid_card.html.haml +++ b/app/views/accounts/_grid_card.html.haml @@ -3,6 +3,6 @@ .avatar= image_tag account.avatar.url(:original) .name = link_to TagManager.instance.url_for(account) do - %span.display_name= display_name(account) + %span.display_name.emojify= display_name(account) %span.username= "@#{account.acct}" - %p.note= truncate(strip_tags(account.note), length: 150) + %p.note.emojify= truncate(strip_tags(account.note), length: 150) diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 1c6b5f0f67640d645492039b1e5959c9ed3d13fe..f575e855e0da06b431ad988ded89f5e1ff9bcc24 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,27 +1,27 @@ -.card{ style: "background-image: url(#{@account.header.url( :original)})" } +.card.h-card.p-author{ style: "background-image: url(#{@account.header.url( :original)})" } - if user_signed_in? && current_account.id != @account.id && !current_account.requested?(@account) .controls - if current_account.following?(@account) = link_to t('accounts.unfollow'), unfollow_account_path(@account), data: { method: :post }, class: 'button' - else = link_to t('accounts.follow'), follow_account_path(@account), data: { method: :post }, class: 'button' - - else + - elsif !user_signed_in? .controls .remote-follow = link_to t('accounts.remote_follow'), account_remote_follow_path(@account), class: 'button' - .avatar= image_tag @account.avatar.url(:original) + .avatar= image_tag @account.avatar.url(:original), class: 'u-photo' %h1.name - = display_name(@account) + %span.p-name.emojify= display_name(@account) %small - = "@#{@account.username}" + %span.p-nickname= "@#{@account.username}" = fa_icon('lock') if @account.locked? .details .bio - .account__header__content= Formatter.instance.simplified_format(@account) + .account__header__content.p-note.emojify= Formatter.instance.simplified_format(@account) .details-counters .counter{ class: active_nav_class(account_url(@account)) } - = link_to account_url(@account) do + = link_to account_url(@account), class: 'u-url u-uid' do %span.counter-label= t('accounts.posts') %span.counter-number= number_with_delimiter @account.statuses.count .counter{ class: active_nav_class(following_account_url(@account)) } diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 7afeb68a97f21c930f513ca7b0cd23d73578aa2f..c194ce33dd3a23e7949ae39ae213ec283237a159 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -12,16 +12,20 @@ %meta{ property: 'og:image', content: full_asset_url(@account.avatar.url(:original)) }/ %meta{ property: 'og:image:width', content: '120' }/ %meta{ property: 'og:image:height', content: '120' }/ + %meta{ property: 'twitter:card', content: 'summary' }/ -= render partial: 'header' +.h-feed + %data.p-name{ value: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/ -- if @statuses.empty? - .accounts-grid - = render partial: 'nothing_here' -- else - .activity-stream - = render partial: 'stream_entries/status', collection: @statuses, as: :status + = render partial: 'header' -.pagination - - if @statuses.size == 20 - = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next' + - if @statuses.empty? + .accounts-grid + = render partial: 'nothing_here' + - else + .activity-stream + = render partial: 'stream_entries/status', collection: @statuses, as: :status + + .pagination + - if @statuses.size == 20 + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next' diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml index aedf163f714d7f68ae4bdc03097ebb69a761dca6..dbaeb4716450aa760008b451def7cfbb9d559549 100644 --- a/app/views/admin/domain_blocks/index.html.haml +++ b/app/views/admin/domain_blocks/index.html.haml @@ -5,10 +5,12 @@ %thead %tr %th Domain + %th Severity %tbody - @blocks.each do |block| %tr %td %samp= block.domain + %td= block.severity = will_paginate @blocks, pagination_options diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5b482213bac1e18d40d36a000a8eeacbb79f4821 --- /dev/null +++ b/app/views/admin/settings/index.html.haml @@ -0,0 +1,36 @@ +- content_for :page_title do + Site Settings + +%table.table + %colgroup + %col{ width: '35%' }/ + %thead + %tr + %th Setting + %th Click to edit + %tbody + %tr + %td{ rowspan: 2 } + %strong Contact information + %td= best_in_place @settings['site_contact_username'], :value, url: admin_setting_path(@settings['site_contact_username']), place_holder: 'Enter a username' + %tr + %td= best_in_place @settings['site_contact_email'], :value, url: admin_setting_path(@settings['site_contact_email']), place_holder: 'Enter a public e-mail address' + %tr + %td + %strong Site description + %br/ + Displayed as a paragraph on the frontpage and used as a meta tag. + %br/ + You can use HTML tags, in particular + %code= '<a>' + and + %code= '<em>' + %td= best_in_place @settings['site_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_description']) + %tr + %td + %strong Extended site description + %br/ + Displayed on extended information page + %br/ + You can use HTML tags + %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description']) \ No newline at end of file diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl new file mode 100644 index 0000000000000000000000000000000000000000..6d9e607db99fe2a5cd8c94cb3e187215b6007240 --- /dev/null +++ b/app/views/api/v1/apps/show.rabl @@ -0,0 +1,3 @@ +object @application + +attributes :name, :website diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index a3391a67e77367649b13ad9e577f2db134462de0..7309a78b8860c16f912b36aa2c390fbaf005e266 100644 --- a/app/views/api/v1/statuses/_show.rabl +++ b/app/views/api/v1/statuses/_show.rabl @@ -1,4 +1,4 @@ -attributes :id, :created_at, :in_reply_to_id, :sensitive, :visibility +attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler_text, :visibility node(:uri) { |status| TagManager.instance.uri_for(status) } node(:content) { |status| Formatter.instance.format(status) } @@ -6,6 +6,10 @@ node(:url) { |status| TagManager.instance.url_for(status) } node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : status.reblogs.count } node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count } +child :application do + extends 'api/v1/apps/show' +end + child :account do extends 'api/v1/accounts/show' end diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl new file mode 100644 index 0000000000000000000000000000000000000000..8ba8dcbb1e6c8bbe6571cbdbdbe2bff3a832ace2 --- /dev/null +++ b/app/views/api/v1/statuses/card.rabl @@ -0,0 +1,5 @@ +object @card + +attributes :url, :title, :description + +node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil } diff --git a/app/views/authorize_follow/_card.html.haml b/app/views/authorize_follow/_card.html.haml index a9b02c74610ba6a34147b745efcd1528b4bdc57a..eef0bec07c35a850a7b99cca4ce378c98d87faa5 100644 --- a/app/views/authorize_follow/_card.html.haml +++ b/app/views/authorize_follow/_card.html.haml @@ -4,8 +4,8 @@ = image_tag account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar' %span.display-name - %strong= display_name(account) + %strong.emojify= display_name(account) %span= "@#{account.acct}" - unless account.note.blank? - .account__header__content= Formatter.instance.simplified_format(account) + .account__header__content.emojify= Formatter.instance.simplified_format(account) diff --git a/app/views/errors/404.html.haml b/app/views/errors/404.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..ba1d5f72dcdd0fd40c870926abdcd69bbe8f6614 --- /dev/null +++ b/app/views/errors/404.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + The page you were looking for doesn't exist + +- content_for :content do + The page you were looking for doesn't exist diff --git a/app/views/errors/410.html.haml b/app/views/errors/410.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..07cf3742fd7b6eb57ee9d4faf2d186089b325fdb --- /dev/null +++ b/app/views/errors/410.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + The page you were looking for doesn't exist anymore + +- content_for :content do + The page you were looking for doesn't exist anymore diff --git a/app/views/errors/422.html.haml b/app/views/errors/422.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..e369cded64dc81d7171e5921c1d2aeecaa1e7642 --- /dev/null +++ b/app/views/errors/422.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + Security verification failed + +- content_for :content do + Security verification failed. Are you blocking cookies? diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 498fae105c24923ec353a48528082fcb238d3825..0147f4064b943e299337e7216ba2457973212541 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,4 +1,7 @@ - content_for :header_tags do + :javascript + window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))} + = javascript_include_tag 'application' = react_component 'Mastodon', default_props, class: 'app-holder', prerender: false diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl new file mode 100644 index 0000000000000000000000000000000000000000..0e9736f5f9f27e80aa033c1c39d57fe0ed245b93 --- /dev/null +++ b/app/views/home/initial_state.json.rabl @@ -0,0 +1,24 @@ +object false + +node(:meta) { + { + access_token: @token, + locale: I18n.locale, + me: current_account.id, + } +} + +node(:compose) { + { + me: current_account.id, + private: current_account.locked?, + } +} + +node(:accounts) { + { + current_account.id => partial('api/v1/accounts/show', object: current_account), + } +} + +node(:settings) { @web_settings } diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..54563f7d87cd84ebaf914a3afb5388fd4c63744f --- /dev/null +++ b/app/views/layouts/error.html.haml @@ -0,0 +1,36 @@ +!!! +%html{:lang => "en"} + %head + %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/ + %meta{:charset => "utf-8"}/ + %title= yield :page_title + %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/ + %link{:href => "https://fonts.googleapis.com/css?family=Roboto:400", :rel => "stylesheet"}/ + :css + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #282c37; + color: #9baec8; + text-align: center; + margin: 0; + padding: 20px; + } + + .dialog img { + display: block; + margin: 20px auto; + margin-top: 50px; + max-width: 600px; + width: 100%; + height: auto; + } + + .dialog h1 { + font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 400; + } + %body + .dialog + %img{:alt => "Mastodon", :src => "/oops.png"}/ + %div + %h1= yield :content diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index e6de7d017e51834fcc6738c5fb5a15aa81f82a07..808fb0a0e6e445a4fcff5bd8a30e8f47359351f8 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -6,6 +6,6 @@ .footer %span.domain= link_to Rails.configuration.x.local_domain, root_path %span.powered-by - = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/Gargron/mastodon')).html_safe + = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/tootsuite/mastodon')).html_safe = render template: "layouts/application" diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index a0860c94bb0d32ed71f8df85e41556da3eb178ae..a9a1d21acdfcbdcebb4598e2c57378f1826d67c2 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -6,14 +6,14 @@ = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } - = f.simple_fields_for :notification_emails, current_user.settings(:notification_emails) do |ff| + = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| = ff.input :follow, as: :boolean, wrapper: :with_label = ff.input :follow_request, as: :boolean, wrapper: :with_label = ff.input :reblog, as: :boolean, wrapper: :with_label = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label - = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff| + = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| = ff.input :must_be_follower, as: :boolean, wrapper: :with_label = ff.input :must_be_following, as: :boolean, wrapper: :with_label diff --git a/app/views/settings/shared/_links.html.haml b/app/views/settings/shared/_links.html.haml index 44f097950bf2f6a4bd55ecc6134ceb55c570b402..a6e90f45711cad37ed1b68b6903b66b26c115116 100644 --- a/app/views/settings/shared/_links.html.haml +++ b/app/views/settings/shared/_links.html.haml @@ -5,3 +5,4 @@ %li= link_to t('settings.preferences'), settings_preferences_path - if controller_name != 'registrations' %li= link_to t('auth.change_password'), edit_user_registration_path + %li= link_to t('settings.back'), root_path \ No newline at end of file diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 94451d3bda03253496779de5d45cdb23aab8a070..6ee8c9e5be8a30fcde2321610d63e643a3625419 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -1,36 +1,46 @@ .detailed-status.light - = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do + = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do %div %div.avatar - = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '' + = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '', class: 'u-photo' %span.display-name - %strong= display_name(status.account) - %span= acct(status.account) + %strong.p-name.emojify= display_name(status.account) + %span.p-nickname= acct(status.account) - .status__content= Formatter.instance.format(status) + .status__content.e-content.p-name.emojify< + - unless status.spoiler_text.blank? + %p= status.spoiler_text + = Formatter.instance.format(status) - unless status.media_attachments.empty? - if status.media_attachments.first.video? .video-player - if status.sensitive? = render partial: 'stream_entries/content_spoiler' - %video{ src: status.media_attachments.first.file.url(:original), loop: true } + %video{ src: status.media_attachments.first.file.url(:original), loop: true, class: 'u-video' } - else .detailed-status__attachments - if status.sensitive? = render partial: 'stream_entries/content_spoiler' - status.media_attachments.each do |media| .media-item - = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener' + = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}" %div.detailed-status__meta - = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: @external_links ? '_blank' : nil, rel: 'noopener' do + %data.dt-published{ value: status.created_at.to_time.iso8601 } + = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: @external_links ? '_blank' : nil, rel: 'noopener' do %span= l(status.created_at) · - %span + - if status.application + - if status.application.website.blank? + %strong.detailed-status__application= status.application.name + - else + = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener' + · + %span< = fa_icon('retweet') %span= status.reblogs.count · - %span + %span< = fa_icon('star') %span= status.favourites.count diff --git a/app/views/stream_entries/_favourite.html.haml b/app/views/stream_entries/_favourite.html.haml index aac90dcdf8c11c85d62ff4d75355e2da7f940308..ea487932853817427b4cd98be81b0cbc51fa83a6 100644 --- a/app/views/stream_entries/_favourite.html.haml +++ b/app/views/stream_entries/_favourite.html.haml @@ -1,5 +1,5 @@ .entry.entry-favourite - .content + .content.emojify %strong= favourite.account.acct = t('stream_entries.favourited') %strong= favourite.status.account.acct diff --git a/app/views/stream_entries/_follow.html.haml b/app/views/stream_entries/_follow.html.haml index 1a2e2c5541565a62f1ac734d5e2d2a232ae169c0..da6d062f0f28c9cebf671cda0966be389ea5269c 100644 --- a/app/views/stream_entries/_follow.html.haml +++ b/app/views/stream_entries/_follow.html.haml @@ -1,5 +1,5 @@ .entry.entry-follow - .content + .content.emojify %strong= link_to follow.account.acct, account_path(follow.account) = t('stream_entries.is_now_following') %strong= link_to follow.target_account.acct, TagManager.instance.url_for(follow.target_account) diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index da3bc0ccb9e113819496346c1d79a03b4d1ad03e..95f90abd97c921dec5706c87d4ffcc5e03d4101c 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -1,17 +1,21 @@ .status.light .status__header .status__meta - = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener' + = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener' + %data.dt-published{ value: status.created_at.to_time.iso8601 } - = link_to TagManager.instance.url_for(status.account), class: 'status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do + = link_to TagManager.instance.url_for(status.account), class: 'status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do .status__avatar %div - = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '' + = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '', class: 'u-photo' %span.display-name - %strong= display_name(status.account) - %span= acct(status.account) + %strong.p-name.emojify= display_name(status.account) + %span.p-nickname= acct(status.account) - .status__content= Formatter.instance.format(status) + .status__content.e-content.p-name.emojify< + - unless status.spoiler_text.blank? + %p= status.spoiler_text + = Formatter.instance.format(status) - unless status.media_attachments.empty? .status__attachments @@ -19,10 +23,10 @@ = render partial: 'stream_entries/content_spoiler' - if status.media_attachments.first.video? .video-item - = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener' do + = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do .video-item__play = fa_icon('play') - else - status.media_attachments.each do |media| .media-item - = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener' + = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}" diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 43935da60ff7e44f87a8a52d8da255796c2a7770..6bad457051b7633d29cfc9dcd1291d4c7ee9c9b1 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -5,7 +5,11 @@ %meta{ property: 'og:site_name', content: 'Mastodon' }/ %meta{ property: 'og:type', content: 'article' }/ %meta{ property: 'og:title', content: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/ - %meta{ property: 'og:description', content: @stream_entry.activity.content }/ + + - if @stream_entry.activity.is_a?(Status) && !@stream_entry.activity.spoiler_text.blank? + %meta{ property: 'og:description', content: @stream_entry.activity.spoiler_text }/ + - else + %meta{ property: 'og:description', content: @stream_entry.activity.content }/ - if @stream_entry.activity.is_a?(Status) && @stream_entry.activity.media_attachments.size > 0 %meta{ property: 'og:image', content: full_asset_url(@stream_entry.activity.media_attachments.first.file.url(:small)) }/ @@ -14,5 +18,7 @@ %meta{ property: 'og:image:width', content: '120' }/ %meta{ property: 'og:image:height', content: '120' }/ + %meta{ property: 'twitter:card', content: 'summary' }/ + .activity-stream.activity-stream-headless = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index dd42fe22c5441b75df9bf16cb24015f1e8c3c10d..412ec4fa5da5a586aef2c965a1029d13866a4fc9 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -2,7 +2,7 @@ .accounts-grid = render partial: 'accounts/nothing_here' - else - .activity-stream + .activity-stream.h-feed = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true .pagination diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..0820490d39f71328d15f862c12d2999f8e5b4741 --- /dev/null +++ b/app/workers/block_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BlockWorker + include Sidekiq::Worker + + def perform(account_id, target_account_id) + AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id)) + end +end diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..af3394b8b33e71f83bb05d7ae42cf776b30add79 --- /dev/null +++ b/app/workers/link_crawl_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class LinkCrawlWorker + include Sidekiq::Worker + + sidekiq_options retry: false + + def perform(status_id) + FetchLinkCardService.new.call(Status.find(status_id)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..0f288f43fe976a44a26b398297b3dc360dd1f83c --- /dev/null +++ b/app/workers/merge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb index 386e94111f54343168b948641f8848b5667099da..e4c38d3844bb6f2d455fa680a8583db6c3a93e33 100644 --- a/app/workers/notification_worker.rb +++ b/app/workers/notification_worker.rb @@ -3,6 +3,8 @@ class NotificationWorker include Sidekiq::Worker + sidekiq_options retry: 5 + def perform(stream_entry_id, target_account_id) SendInteractionService.new.call(StreamEntry.find(stream_entry_id), Account.find(target_account_id)) end diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb index 489bd835904a633578bea86af0a32b8de5251552..868fd9f972c8015d5292d0f58d887279bf278a87 100644 --- a/app/workers/pubsubhubbub/confirmation_worker.rb +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -4,7 +4,7 @@ class Pubsubhubbub::ConfirmationWorker include Sidekiq::Worker include RoutingHelper - sidekiq_options queue: 'push' + sidekiq_options queue: 'push', retry: false def perform(subscription_id, mode, secret = nil, lease_seconds = nil) subscription = Subscription.find(subscription_id) diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 35bf7b2f07e7f4e3263ecdf33f7f7b3bdb144500..15005bc80200a02a88edd32eefd719fad29d5458 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -4,7 +4,11 @@ class Pubsubhubbub::DeliveryWorker include Sidekiq::Worker include RoutingHelper - sidekiq_options queue: 'push', retry: 5 + sidekiq_options queue: 'push', retry: 3, dead: false + + sidekiq_retry_in do |count| + 5 * (count + 1) + end def perform(subscription_id, payload) subscription = Subscription.find(subscription_id) diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 84eae73befa997fa1afe41ce2c9321c4219fc5ff..593edd032f2e62c06019912908c95f8cb28fab74 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -3,6 +3,8 @@ class ThreadResolveWorker include Sidekiq::Worker + sidekiq_options retry: false + def perform(child_status_id, parent_url) child_status = Status.find(child_status_id) parent_status = FetchRemoteStatusService.new.call(parent_url) diff --git a/app/workers/unfavourite_worker.rb b/app/workers/unfavourite_worker.rb index a14c82d6fcec4ce968e5db098d09881b9da4888c..cce07e486c7106647fddec8a24446e626ca6d49e 100644 --- a/app/workers/unfavourite_worker.rb +++ b/app/workers/unfavourite_worker.rb @@ -5,5 +5,7 @@ class UnfavouriteWorker def perform(account_id, status_id) UnfavouriteService.new.call(Account.find(account_id), Status.find(status_id)) + rescue ActiveRecord::RecordNotFound + true end end diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..dbf7243de752584c1304346f9572f2d48fca1cce --- /dev/null +++ b/app/workers/unmerge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UnmergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end diff --git a/config/application.rb b/config/application.rb index 79ace8521cb724fd73ef479961aa91f69f66dae2..d0b06bf9543844120ad5cf40e4d4bdb87000b72e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -3,6 +3,7 @@ require_relative 'boot' require 'rails/all' require_relative '../app/lib/exceptions' +require_relative '../lib/statsd_monitor' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -30,6 +31,8 @@ module Mastodon config.active_job.queue_adapter = :sidekiq + config.middleware.insert(0, ::StatsDMonitor) + config.middleware.insert_before 0, Rack::Cors do allow do origins '*' @@ -46,6 +49,7 @@ module Mastodon config.to_prepare do Doorkeeper::AuthorizationsController.layout 'public' + Doorkeeper::Application.send :include, ApplicationExtension end config.action_dispatch.default_headers = { diff --git a/config/cable.yml b/config/cable.yml index 978f721afef039ab400725d6d36f014dfcaff19f..34759a772dcafb855afc71e1d11fd26f1079a3da 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -7,4 +7,4 @@ test: production: adapter: redis - url: redis://<%= ENV['REDIS_HOST'] || 'localhost' %>:<%= ENV['REDIS_PORT'] || 6379 %>/1 + url: redis://<%= ENV['REDIS_PASSWORD'] ? ':' + ENV['REDIS_PASSWORD'] + '@' : '' %><%= ENV['REDIS_HOST'] || 'localhost' %>:<%= ENV['REDIS_PORT'] || 6379 %>/1 diff --git a/config/database.yml b/config/database.yml index 52c26f599c148969e63b1f68ae2eb4cfbd16e0b9..5ec342f939f8bf31283b5cd7ed8c01f91adf0e8f 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,6 +1,6 @@ default: &default adapter: postgresql - pool: <%= ENV["DB_POOL"] || ENV['RAILS_MAX_THREADS'] || 5 %> + pool: <%= ENV["DB_POOL"] || ENV['MAX_THREADS'] || 5 %> timeout: 5000 encoding: unicode diff --git a/config/environments/development.rb b/config/environments/development.rb index 829edcf0410a1eb4c955bd1b909633fb9ff52233..3f44d861e68cb50caefdb0ea6a04705a6b4f92e9 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -62,7 +62,10 @@ Rails.application.configure do # routes, locales, etc. This feature depends on the listen gem. # config.file_watcher = ActiveSupport::EventedFileUpdateChecker - config.action_mailer.delivery_method = :letter_opener + # If using a Heroku, Vagrant or generic remote development environment, + # use letter_opener_web, accessible at /letter_opener. + # Otherwise, use letter_opener, which launches a browser window to view sent mail. + config.action_mailer.delivery_method = (ENV['HEROKU'] || ENV['VAGRANT'] || ENV['REMOTE_DEV']) ? :letter_opener_web : :letter_opener config.after_initialize do Bullet.enable = true diff --git a/config/environments/production.rb b/config/environments/production.rb index 9254d494cefe98898950ae2b7c924b1ee77b5cb3..d2dfa4274498465fad7cd434b0b18e2d43f719bc 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -32,6 +32,9 @@ Rails.application.configure do # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + # Allow to specify public IP of reverse proxy if it's needed + config.action_dispatch.trusted_proxies = [IPAddr.new(ENV['TRUSTED_PROXY_IP'])] unless ENV['TRUSTED_PROXY_IP'].blank? + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = false @@ -45,10 +48,20 @@ Rails.application.configure do # Use a different logger for distributed setups. # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) + # Parse and split the REDIS_URL if passed (used with hosting platforms such as Heroku). + # Set ENV variables because they are used elsewhere. + if ENV['REDIS_URL'] + redis_url = URI.parse(ENV['REDIS_URL']) + ENV['REDIS_HOST'] = redis_url.host + ENV['REDIS_PORT'] = redis_url.port.to_s + ENV['REDIS_PASSWORD'] = redis_url.password + end + # Use a different cache store in production. config.cache_store = :redis_store, { host: ENV.fetch('REDIS_HOST') { 'localhost' }, port: ENV.fetch('REDIS_PORT') { 6379 }, + password: ENV.fetch('REDIS_PASSWORD') { false }, db: 0, namespace: 'cache', expires_in: 20.minutes @@ -85,7 +98,7 @@ Rails.application.configure do :address => ENV['SMTP_SERVER'], :user_name => ENV['SMTP_LOGIN'], :password => ENV['SMTP_PASSWORD'], - :domain => config.x.local_domain, + :domain => ENV['SMTP_DOMAIN'] || config.x.local_domain, :authentication => :plain, } @@ -94,4 +107,8 @@ Rails.application.configure do config.react.variant = :production config.active_record.logger = nil + + config.to_prepare do + StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank? + end end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9dc846101320c96a5ce8aceb8c96ec098..b5e43e705f9ba5d4c8a3c8635ab96882598fae1d 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -10,7 +10,7 @@ # inflect.uncountable %w( fish sheep ) # end -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym 'StatsD' + inflect.acronym 'OEmbed' +end diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index cb7ed448714ac90235cd7a447e48bbafa77e1903..71a7b514ec9a27884685ec4c9f8cdb9349af5fd5 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +Paperclip.options[:read_timeout] = 60 + if ENV['S3_ENABLED'] == 'true' Aws.eager_autoload!(services: %w(S3)) @@ -9,7 +11,7 @@ if ENV['S3_ENABLED'] == 'true' Paperclip::Attachment.default_options[:s3_host_name] = ENV.fetch('S3_HOSTNAME') { "s3-#{ENV.fetch('S3_REGION')}.amazonaws.com" } Paperclip::Attachment.default_options[:path] = '/:class/:attachment/:id_partition/:style/:filename' Paperclip::Attachment.default_options[:s3_headers] = { 'Cache-Control' => 'max-age=315576000', 'Expires' => 10.years.from_now.httpdate } - Paperclip::Attachment.default_options[:s3_permissions] = 'public' + Paperclip::Attachment.default_options[:s3_permissions] = 'public-read' Paperclip::Attachment.default_options[:s3_region] = ENV.fetch('S3_REGION') { 'us-east-1' } Paperclip::Attachment.default_options[:s3_credentials] = { diff --git a/config/initializers/redis.rb b/config/initializers/redis.rb index 3825710b87a7d18f4fe039c66fc4c47e2160e295..3660c4a9bdfb125cd44ebc9d78d304512d84d6de 100644 --- a/config/initializers/redis.rb +++ b/config/initializers/redis.rb @@ -3,5 +3,6 @@ Redis.current = Redis.new( host: ENV.fetch('REDIS_HOST') { 'localhost' }, port: ENV.fetch('REDIS_PORT') { 6379 }, + password: ENV.fetch('REDIS_PASSWORD') { false }, driver: :hiredis ) diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 63fdb3f1658890743c7aa9770352fb0c42c27df6..ecdd07b08dad2fe8b34df9d4dfccb8624ab5fb43 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,10 +1,11 @@ host = ENV.fetch('REDIS_HOST') { 'localhost' } port = ENV.fetch('REDIS_PORT') { 6379 } +password = ENV.fetch('REDIS_PASSWORD') { false } Sidekiq.configure_server do |config| - config.redis = { host: host, port: port } + config.redis = { host: host, port: port, password: password} end Sidekiq.configure_client do |config| - config.redis = { host: host, port: port } + config.redis = { host: host, port: port, password: password } end diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb new file mode 100644 index 0000000000000000000000000000000000000000..c9c754e7ff7642dc3b91b35a61836b2bee545205 --- /dev/null +++ b/config/initializers/statsd.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +StatsD.prefix = 'mastodon' +StatsD.default_sample_rate = 1 + +StatsDMonitor.extend(StatsD::Instrument) +StatsDMonitor.statsd_measure(:call, 'request.duration') + +STATSD_REQUEST_METRICS = { + 'request.status.success' => 200, + 'request.status.not_found' => 404, + 'request.status.too_many_requests' => 429, + 'request.status.internal_server_error' => 500, +}.freeze + +STATSD_REQUEST_METRICS.each do |name, code| + StatsDMonitor.statsd_count_if(:call, name) do |status, _env, _body| + status.to_i == code + end +end diff --git a/config/initializers/trusted_proxies.rb b/config/initializers/trusted_proxies.rb new file mode 100644 index 0000000000000000000000000000000000000000..3c2afd8cdb0048bf39ae0b0f523b0fd962e252fa --- /dev/null +++ b/config/initializers/trusted_proxies.rb @@ -0,0 +1,11 @@ +module Rack + class Request + def trusted_proxy?(ip) + if Rails.application.config.action_dispatch.trusted_proxies.nil? + super + else + Rails.application.config.action_dispatch.trusted_proxies.any? { |proxy| proxy === ip } + end + end + end +end diff --git a/config/locales/de.yml b/config/locales/de.yml index ead3ae514435fd395cd09889ed2b44171c0f7172..f36cc64c8a75b70dd2b8d20cf9b805636a1b5079 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -14,6 +14,7 @@ de: people_followed_by: Nutzer, denen %{name} folgt people_who_follow: Nutzer, die %{name} folgen posts: Beiträge + remote_follow: Folgen unfollow: Entfolgen application_mailer: signature: Mastodon-Benachrichtigungen von %{instance} @@ -26,6 +27,25 @@ de: resend_confirmation: Bestätigung nochmal versenden reset_password: Passwort zurücksetzen set_new_password: Neues Passwort setzen + authorize_follow: + error: Das entfernte Profil konnte nicht geladen werden + follow: Folgen + prompt_html: 'Du (<strong>%{self}</strong>) möchtest dieser Person folgen:' + title: "%{acct} folgen" + datetime: + distance_in_words: + about_x_hours: "%{count}h" + about_x_months: "%{count}mo" + about_x_years: "%{count}y" + almost_x_years: "%{count}y" + half_a_minute: Gerade eben + less_than_x_minutes: "%{count}m" + less_than_x_seconds: Gerade eben + over_x_years: "%{count}y" + x_days: "%{count}d" + x_minutes: "%{count}m" + x_months: "%{count}mo" + x_seconds: "%{count}s" generic: changes_saved_msg: Änderungen gespeichert! powered_by: angetrieben von %{link} @@ -40,6 +60,9 @@ de: follow: body: "%{name} folgt dir jetzt!" subject: "%{name} folgt dir nun" + follow_request: + body: "%{name} möchte dir folgen:" + subject: "%{name} möchte dir folgen" mention: body: "%{name} hat dich erwähnt:" subject: "%{name} hat dich erwähnt" @@ -49,13 +72,23 @@ de: pagination: next: Vorwärts prev: Zurück + remote_follow: + acct: Dein Nutzername@Domain, von dem du dieser Person folgen möchtest + missing_resource: Die erforderliche Weiterleitungs-URL konnte leider in deinem Profil nicht gefunden werden + proceed: Weiter + prompt: 'Du wirst dieser Person folgen:' settings: edit_profile: Profil bearbeiten preferences: Einstellungen stream_entries: + click_to_show: Klicken um zu zeigen favourited: favorisierte einen Beitrag von is_now_following: folgt nun reblogged: teilte + sensitive_content: Sensible Inhalte + time: + formats: + default: "%d.%m.%Y %H:%M" users: invalid_email: Inkorrekte E-mail-Addresse will_paginate: diff --git a/config/locales/en.yml b/config/locales/en.yml index e166fc7170ac97fec74bf1a77984e035bb6b2a77..831fdbc7ae81883cb34107e5ee6caa9c3cf9e80a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3,9 +3,19 @@ en: about: about_instance: "<em>%{instance}</em> is a Mastodon instance." about_mastodon: Mastodon is a <em>free, open-source</em> social network server. A <em>decentralized</em> alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the <em>social network</em> seamlessly. + business_email: 'Business e-mail:' + contact: Contact + domain_count_after: other instances + domain_count_before: Connected to get_started: Get started + learn_more: Learn more + links: Links source_code: Source code + status_count_after: statuses + status_count_before: Who authored terms: Terms + user_count_after: users + user_count_before: Home to accounts: follow: Follow followers: Followers @@ -18,6 +28,8 @@ en: unfollow: Unfollow application_mailer: signature: Mastodon notifications from %{instance} + applications: + invalid_url: The provided URL is invalid auth: change_password: Change password didnt_get_confirmation: Didn't receive confirmation instructions? @@ -67,8 +79,8 @@ en: body: 'You were mentioned by %{name} in:' subject: You were mentioned by %{name} reblog: - body: 'Your status was reblogged by %{name}:' - subject: "%{name} reblogged your status" + body: 'Your status was boosted by %{name}:' + subject: "%{name} boosted your status" pagination: next: Next prev: Prev @@ -78,8 +90,11 @@ en: proceed: Proceed to follow prompt: 'You are going to follow:' settings: + back: Back to Mastodon edit_profile: Edit profile preferences: Preferences + statuses: + over_character_limit: character limit of %{max} exceeded stream_entries: click_to_show: Click to show favourited: favourited a post by diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index d0aed9d0eda1e9e4960cee715b3b3c7a12edf57e..614cd49114f154889a8826ad215bf7334a61685c 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -1,6 +1,9 @@ --- de: simple_form: + hints: + defaults: + locked: Erlaubt dir, Folger zu überprüfen, bevor sie dir folgen können labels: defaults: avatar: Avatar @@ -11,6 +14,7 @@ de: email: E-mail-Addresse header: Kopfbild locale: Sprache + locked: Gesperrter Profil new_password: Neues Passwort note: Ãœber mich password: Passwort @@ -21,6 +25,7 @@ de: notification_emails: favourite: E-mail senden, wenn jemand meinen Beitrag favorisiert follow: E-mail senden, wenn mir jemand folgt + follow_request: E-mail senden, wenn mir jemand folgen möchte mention: E-mail senden, wenn mich jemand erwähnt reblog: E-mail senden, wenn jemand meinen Beitrag teilt 'no': Nein diff --git a/config/navigation.rb b/config/navigation.rb index 1b6615ed0289a36fe4fcf4cb4321b8eb79d623ec..9aaa12b0b75de644ca6bf2cadc2f132b581c85cd 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -7,5 +7,6 @@ SimpleNavigation::Configuration.run do |navigation| primary.item :domain_blocks, safe_join([fa_icon('lock fw'), 'Domain Blocks']), admin_domain_blocks_url primary.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url primary.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url + primary.item :settings, safe_join([fa_icon('cogs fw'), 'Site Settings']), admin_settings_url end end diff --git a/config/puma.rb b/config/puma.rb index ad2dbfffde90d6d05887f92e942f04b54d801fde..550129bdc604595cd5e9ac43682c53df1bcaf936 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,47 +1,18 @@ -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum, this matches the default thread size of Active Record. -# -threads_count = ENV.fetch("MAX_THREADS") { 5 }.to_i +threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i threads threads_count, threads_count -# Specifies the `port` that Puma will listen on to receive requests, default is 3000. -# -port ENV.fetch("PORT") { 3000 } +port ENV.fetch('PORT') { 3000 } +environment ENV.fetch('RAILS_ENV') { 'development' } +workers ENV.fetch('WEB_CONCURRENCY') { 2 } -# Specifies the `environment` that Puma will run in. -# -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the number of `workers` to boot in clustered mode. -# Workers are forked webserver processes. If using threads and workers together -# the concurrency of the application would be max `threads` * `workers`. -# Workers do not work on JRuby or Windows (both of which do not support -# processes). -# -workers ENV.fetch("WEB_CONCURRENCY") { 2 } - -# Use the `preload_app!` method when specifying a `workers` number. -# This directive tells Puma to first boot the application and load code -# before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. If you use this option -# you need to make sure to reconnect any threads in the `on_worker_boot` -# block. -# preload_app! -# The code in the `on_worker_boot` will be called if you are using -# clustered mode by specifying a number of `workers`. After each worker -# process is booted this block will be run, if you are using `preload_app!` -# option you will want to use this block to reconnect to any threads -# or connections that may have been created at application boot, Ruby -# cannot share connections between processes. -# on_worker_boot do + if ENV['HEROKU'] # Spawn the workers from Puma, to only use one dyno + @sidekiq_pid ||= spawn('bundle exec sidekiq -q default -q mailers -q push') + end + ActiveRecord::Base.establish_connection if defined?(ActiveRecord) end -# Allow puma to be restarted by `rails restart` command. plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index 18c239c48a5097c8af499bec3d8518cf53c88309..15fb924f117b487de9ccd7575cc9987fa7174d75 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ require 'sidekiq/web' Rails.application.routes.draw do + mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development? mount ActionCable.server, at: 'cable' authenticate :user, lambda { |u| u.admin? } do @@ -58,6 +59,7 @@ Rails.application.routes.draw do namespace :admin do resources :pubsubhubbub, only: [:index] resources :domain_blocks, only: [:index, :create] + resources :settings, only: [:index, :update] resources :accounts, only: [:index, :show, :update] do member do @@ -85,6 +87,7 @@ Rails.application.routes.draw do resources :statuses, only: [:create, :show, :destroy] do member do get :context + get :card get :reblogged_by get :favourited_by @@ -100,10 +103,11 @@ Rails.application.routes.draw do get '/timelines/public', to: 'timelines#public', as: :public_timeline get '/timelines/tag/:id', to: 'timelines#tag', as: :hashtag_timeline - resources :follows, only: [:create] - resources :media, only: [:create] - resources :apps, only: [:create] - resources :blocks, only: [:index] + resources :follows, only: [:create] + resources :media, only: [:create] + resources :apps, only: [:create] + resources :blocks, only: [:index] + resources :favourites, only: [:index] resources :follow_requests, only: [:index] do member do @@ -112,8 +116,11 @@ Rails.application.routes.draw do end end - resources :notifications, only: [:index] - resources :favourites, only: [:index] + resources :notifications, only: [:index, :show] do + collection do + post :clear + end + end resources :accounts, only: [:show] do collection do @@ -134,12 +141,17 @@ Rails.application.routes.draw do end end end + + namespace :web do + resource :settings, only: [:update] + end end - get '/web/*any', to: 'home#index', as: :web + get '/web/(*any)', to: 'home#index', as: :web - get :about, to: 'about#index' - get :terms, to: 'about#terms' + get '/about', to: 'about#index' + get '/about/more', to: 'about#more' + get '/terms', to: 'about#terms' root 'home#index' diff --git a/config/settings.yml b/config/settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..a78bd067d53246beebb4102d7d1e38d750e3f53f --- /dev/null +++ b/config/settings.yml @@ -0,0 +1,23 @@ +# config/app.yml for rails-settings-cached +defaults: &defaults + site_description: '' + site_extended_description: '' + site_contact_username: '' + site_contact_email: '' + notification_emails: + follow: false + reblog: false + favourite: false + mention: false + follow_request: true + interactions: + must_be_follower: false + must_be_following: false +development: + <<: *defaults + +test: + <<: *defaults + +production: + <<: *defaults diff --git a/db/migrate/20170105224407_add_shortcode_to_media_attachments.rb b/db/migrate/20170105224407_add_shortcode_to_media_attachments.rb new file mode 100644 index 0000000000000000000000000000000000000000..2685ae150ec62b6ecf09c376829ddb400c14ec61 --- /dev/null +++ b/db/migrate/20170105224407_add_shortcode_to_media_attachments.rb @@ -0,0 +1,14 @@ +class AddShortcodeToMediaAttachments < ActiveRecord::Migration[5.0] + def up + add_column :media_attachments, :shortcode, :string, null: true, default: nil + add_index :media_attachments, :shortcode, unique: true + + # Migrate old links + MediaAttachment.local.update_all('shortcode = id') + end + + def down + remove_index :media_attachments, :shortcode + remove_column :media_attachments, :shortcode + end +end diff --git a/db/migrate/20170109120109_create_web_settings.rb b/db/migrate/20170109120109_create_web_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..2aeae1f919438c0e9cb934547d0676c04cf5f5d3 --- /dev/null +++ b/db/migrate/20170109120109_create_web_settings.rb @@ -0,0 +1,12 @@ +class CreateWebSettings < ActiveRecord::Migration[5.0] + def change + create_table :web_settings do |t| + t.integer :user_id + t.json :data + + t.timestamps + end + + add_index :web_settings, :user_id, unique: true + end +end diff --git a/db/migrate/20170112154826_migrate_settings.rb b/db/migrate/20170112154826_migrate_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..f6f6ed5315e1c75163e4c35e87a36b93bb47ac1c --- /dev/null +++ b/db/migrate/20170112154826_migrate_settings.rb @@ -0,0 +1,19 @@ +class MigrateSettings < ActiveRecord::Migration + def up + remove_index :settings, [:target_type, :target_id, :var] + rename_column :settings, :target_id, :thing_id + rename_column :settings, :target_type, :thing_type + change_column :settings, :thing_id, :integer, null: true, default: nil + change_column :settings, :thing_type, :string, null: true, default: nil + add_index :settings, [:thing_type, :thing_id, :var], unique: true + end + + def down + remove_index :settings, [:thing_type, :thing_id, :var] + rename_column :settings, :thing_id, :target_id + rename_column :settings, :thing_type, :target_type + change_column :settings, :target_id, :integer, null: false + change_column :settings, :target_type, :string, null: false, default: '' + add_index :settings, [:target_type, :target_id, :var], unique: true + end +end diff --git a/db/migrate/20170114194937_add_application_to_statuses.rb b/db/migrate/20170114194937_add_application_to_statuses.rb new file mode 100644 index 0000000000000000000000000000000000000000..b699db2ac6bb1f8ff7648e6939552402fc97095b --- /dev/null +++ b/db/migrate/20170114194937_add_application_to_statuses.rb @@ -0,0 +1,5 @@ +class AddApplicationToStatuses < ActiveRecord::Migration[5.0] + def change + add_column :statuses, :application_id, :int + end +end diff --git a/db/migrate/20170114203041_add_website_to_oauth_application.rb b/db/migrate/20170114203041_add_website_to_oauth_application.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee674be7228417071c0be651a39c44a8735dceb1 --- /dev/null +++ b/db/migrate/20170114203041_add_website_to_oauth_application.rb @@ -0,0 +1,5 @@ +class AddWebsiteToOauthApplication < ActiveRecord::Migration[5.0] + def change + add_column :oauth_applications, :website, :string + end +end diff --git a/db/migrate/20170119214911_create_preview_cards.rb b/db/migrate/20170119214911_create_preview_cards.rb new file mode 100644 index 0000000000000000000000000000000000000000..70ed91bbd5a1939ff7d5cdcdec5ba438e43b3934 --- /dev/null +++ b/db/migrate/20170119214911_create_preview_cards.rb @@ -0,0 +1,17 @@ +class CreatePreviewCards < ActiveRecord::Migration[5.0] + def change + create_table :preview_cards do |t| + t.integer :status_id + t.string :url, null: false, default: '' + + # OpenGraph + t.string :title, null: true + t.string :description, null: true + t.attachment :image + + t.timestamps + end + + add_index :preview_cards, :status_id, unique: true + end +end diff --git a/db/migrate/20170123162658_add_severity_to_domain_blocks.rb b/db/migrate/20170123162658_add_severity_to_domain_blocks.rb new file mode 100644 index 0000000000000000000000000000000000000000..dcbc32a1afa38f3ededec12d2d2934c022c2573c --- /dev/null +++ b/db/migrate/20170123162658_add_severity_to_domain_blocks.rb @@ -0,0 +1,5 @@ +class AddSeverityToDomainBlocks < ActiveRecord::Migration[5.0] + def change + add_column :domain_blocks, :severity, :integer, default: 0 + end +end diff --git a/db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb b/db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb new file mode 100644 index 0000000000000000000000000000000000000000..999fccda05c58354f21e56e2f9d95e2da307d090 --- /dev/null +++ b/db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb @@ -0,0 +1,5 @@ +class AddRejectMediaToDomainBlocks < ActiveRecord::Migration[5.0] + def change + add_column :domain_blocks, :reject_media, :boolean + end +end diff --git a/db/migrate/20170125145934_add_spoiler_text_to_statuses.rb b/db/migrate/20170125145934_add_spoiler_text_to_statuses.rb new file mode 100644 index 0000000000000000000000000000000000000000..2c43210babdff86e06f98c851f7ee50aabd961d4 --- /dev/null +++ b/db/migrate/20170125145934_add_spoiler_text_to_statuses.rb @@ -0,0 +1,5 @@ +class AddSpoilerTextToStatuses < ActiveRecord::Migration[5.0] + def change + add_column :statuses, :spoiler_text, :text, default: "", null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b9236d42fdced963d33d16e9a973e7ccd9390356..72ce63133e489aa88771988becf4c9b93a87351a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161222204147) do +ActiveRecord::Schema.define(version: 20170125145934) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -55,9 +55,11 @@ ActiveRecord::Schema.define(version: 20161222204147) do end create_table "domain_blocks", force: :cascade do |t| - t.string "domain", default: "", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "domain", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "severity", default: 0 + t.boolean "reject_media" t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true, using: :btree end @@ -95,6 +97,8 @@ ActiveRecord::Schema.define(version: 20161222204147) do t.integer "account_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "shortcode" + t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree end @@ -151,30 +155,32 @@ ActiveRecord::Schema.define(version: 20161222204147) do t.datetime "created_at" t.datetime "updated_at" t.boolean "superapp", default: false, null: false + t.string "website" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree end - create_table "pubsubhubbub_subscriptions", force: :cascade do |t| - t.string "topic", default: "", null: false - t.string "callback", default: "", null: false - t.string "mode", default: "", null: false - t.string "challenge", default: "", null: false - t.string "secret" - t.boolean "confirmed", default: false, null: false - t.datetime "expires_at", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["topic", "callback"], name: "index_pubsubhubbub_subscriptions_on_topic_and_callback", unique: true, using: :btree + create_table "preview_cards", force: :cascade do |t| + t.integer "status_id" + t.string "url", default: "", null: false + t.string "title" + t.string "description" + t.string "image_file_name" + t.string "image_content_type" + t.integer "image_file_size" + t.datetime "image_updated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree end create_table "settings", force: :cascade do |t| - t.string "var", null: false + t.string "var", null: false t.text "value" - t.string "target_type", null: false - t.integer "target_id", null: false + t.string "thing_type" + t.integer "thing_id" t.datetime "created_at" t.datetime "updated_at" - t.index ["target_type", "target_id", "var"], name: "index_settings_on_target_type_and_target_id_and_var", unique: true, using: :btree + t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true, using: :btree end create_table "statuses", force: :cascade do |t| @@ -189,7 +195,8 @@ ActiveRecord::Schema.define(version: 20161222204147) do t.boolean "sensitive", default: false t.integer "visibility", default: 0, null: false t.integer "in_reply_to_account_id" - t.string "conversation_uri" + t.integer "application_id" + t.text "spoiler_text", default: "", null: false t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree @@ -258,4 +265,12 @@ ActiveRecord::Schema.define(version: 20161222204147) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree end + create_table "web_settings", force: :cascade do |t| + t.integer "user_id" + t.json "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true, using: :btree + end + end diff --git a/docs/Contributing-to-Mastodon/Sponsors.md b/docs/Contributing-to-Mastodon/Sponsors.md new file mode 100644 index 0000000000000000000000000000000000000000..5916ceb4504b753d956327547419daee757cb386 --- /dev/null +++ b/docs/Contributing-to-Mastodon/Sponsors.md @@ -0,0 +1,31 @@ +Sponsors +======== + +These people make the development of Mastodon possible through [Patreon](https://www.patreon.com/user?u=619786): + +**Extra special Patrons** + +- [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist) +- [glocal](https://mastodon.social/users/glocal) +- [Jimmy Tidey](https://mastodon.social/users/jimmytidey) +- [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene) +- [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave) + +**Thank you to the following people** + +- [Sophia Park](https://mastodon.social/users/sophia) +- [WelshPixie](https://mastodon.social/users/WelshPixie) +- [John Parker](https://mastodon.social/users/Middaparka) +- [Christina Hendricks](https://mastodon.social/users/clhendricksbc) +- [Jelle](http://jelv.nl) +- [Harris Bomberguy](https://mastodon.social/users/Hbomberguy) +- [Martin Tithonium](https://mastodon.social/users/tithonium) +- [Edward Saperia](https://nwspk.com) +- [Yoz Grahame](http://yoz.com/) +- [Jenn Kaplan](https://gay.crime.team/users/jkap) +- [Natalie Weizenbaum](https://mastodon.social/users/nex3) +- [Matteo De Micheli](http://matteodem.ch/) +- [BirdMachine](https://mastodon.social/users/BirdMachine) +- [Jessica Hayley](https://mastodon.social/users/jayhay) +- [Niels Roesen Abildgaard](http://hypesystem.dk/) +- [Zatnosk](https://github.com/Zatnosk) diff --git a/docs/Contributing-to-Mastodon/Translating.md b/docs/Contributing-to-Mastodon/Translating.md new file mode 100644 index 0000000000000000000000000000000000000000..d47e83e7e10e22614a7d5da60e22532d998dd0d0 --- /dev/null +++ b/docs/Contributing-to-Mastodon/Translating.md @@ -0,0 +1,48 @@ +Translating +=========== + +If you want to localise Mastodon into your language, here is how. + +There are two parts to Mastodon, the server and the web client. The translations for the web client are in `app/assets/javascripts/components/locales`. For the server-side, the translations live in `config/locales` and are divided into different files. Here are all the files you’ll need to translate: + +| Original file (English) | Location | Description | +|---|---|---| +| [`en.jsx`](/Gargron/mastodon/tree/master/app/assets/javascripts/components/locales/en.jsx) | `app/assets/javascripts/components/locales/en.jsx` | Strings for the web client | +| [`en.yml`](/Gargron/mastodon/tree/master/config/locales/en.yml) | `config/locales/en.yml` | Strings for general use | +| [`simple_form.en.yml`](/Gargron/mastodon/tree/master/config/locales/simple_form.en.yml) | `config/locales/simple_form.en.yml` | Strings for the settings area | +| [`devise.en.yml`](/Gargron/mastodon/tree/master/config/locales/devise.en.yml) | `config/locales/devise.en.yml` | Generic strings for Devise | +| [`doorkeeper.en.yml`](/Gargron/mastodon/tree/master/config/locales/doorkeeper.en.yml) | `config/locales/doorkeeper.en.yml` | Generic strings for Doorkeeper | + +## Translating + +If you use Github, first clone the Mastodon repository to your account. + +1. Duplicate the files in their folder and replace `en` in the filenames by your language’s standard two-letters code ([ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)). + For instance `simple_form.en.yml` becomes `simple_form.es.yml` in the Spanish translation. +2. Also replace the language code in the first lines of all the files, and the last line of the `.jsx` file. +3. Translate the right-side values from English to your language. Keep the indentation and punctuation. + +Since Devise and Doorkeeper are popular libraries, there may already be translation files for your language available on the Internet. + +## Declaring the language + +The locales are mentioned in several other files. To activate your translation, add your language code to the different lists present in these files: + +| File | Location | Comment | +|---|---|---| +| [`index.jsx`](/Gargron/mastodon/tree/master/app/assets/javascripts/components/locales/index.jsx) | `app/assets/javascripts/components/locales/index.jsx` | 2 lines to add | +|[`mastodon.jsx`](/Gargron/mastodon/tree/master/app/assets/javascripts/components/containers/mastodon.jsx) | `app/assets/javascripts/components/containers/mastodon.jsx` | 1 line to add + 1 list to complete | +| [`settings_helper.rb`](/Gargron/mastodon/tree/master/app/helpers/settings_helper.rb) | `app/helpers/settings_helper.rb` | 1 line to add + your language’s name | +| [`application.rb`](/Gargron/mastodon/tree/master/config/application.rb) | `config/application.rb` | 1 list to complete | + +## Sending the translation + +You can then push the files to git and submit a pull request. + +## Testing the translation + +Once the pull request is accepted, wait for the code to be deployed on a Mastodon instance. Log-in with your account there, and change the locale in the settings. Browse and use the website. See if everything makes sense in context and if anything seems out of place or breaks the layout. Invite other Mastodon users speaking your language to try it and give feedback. Make changes accordingly and update the translation. + +## Updating the translation + +Keep an eye on the original English files in `app/assets/javascripts/components/locales` and `config/locales`. When they are updated, pass on the changes to your language files. For new strings, add the new lines to the same position and translate them. Once you’re finished with the updates, you can submit a new pull request. diff --git a/docs/Extensions.md b/docs/Extensions.md new file mode 100644 index 0000000000000000000000000000000000000000..a3d64ebf1c498bb8f9561939150b1e2b42c7cf5a --- /dev/null +++ b/docs/Extensions.md @@ -0,0 +1,15 @@ +Protocol extensions +=================== + +Some functionality in Mastodon required some additions to the protocols to enable seamless federation of those features: + +1. ActivityStreams was lacking verbs for block/unblock. Mastodon creates Salmon slaps for block and unblock events, which are not part of a user's public feed, but are nevertheless delivered to the target user. The intent of these Salmon slaps is not to notify the target user, but to notify the target user's server, so that it can perform any number of UX-related tasks such as removing the target user as a follower of the blocker, and/or displaying a message to the target user such as "You can't follow this person because you've been blocked" + + The Salmon slaps have the exact same structure as standard follow/unfollow slaps, the verbs are namespaced: + + - `http://mastodon.social/schema/1.0/block` + - `http://mastodon.social/schema/1.0/unblock` + +2. Statuses can be marked as containing sensitive (or not safe for work) media. This is symbolized by a `<category term="nsfw" />` on the Atom entry + +3. Statuses that are intended to be listed publicly on e.g. "whole known network" or "public" timelines contain a `<link rel="mentioned" href="http://activityschema.org/collection/public" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection"/>`. Conversely, statuses which do not contain that, are intended to be low key, unlisted diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0005937610eb604645b89b1fc8a870e7761917b8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,24 @@ +Index +===== + +**Mastodon** is a free, open-source GNU social-compatible social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. + +### Using Mastodon +- [Frequently Asked Questions](Using-Mastodon/FAQ.md) +- [List of Mastodon instances](Using-Mastodon/List-of-Mastodon-instances.md) +- [Apps](Using-Mastodon/Apps.md) + +### Using the API +- [API documentation](Using-the-API/API.md) +- [Testing the API with cURL](Using-the-API/Testing-with-cURL.md) +- [OAuth details](Using-the-API/OAuth-details.md) +- [Tips for app developers](Using-the-API/Tips-for-app-developers.md) + +### Running Mastodon +- [Production guide](Running-Mastodon/Production-guide.md) +- [Development guide](Running-Mastodon/Development-guide.md) + +### Contributing to Mastodon +- [Sponsors](Contributing-to-Mastodon/Sponsors.md) +- [Translate Mastodon in your language](Contributing-to-Mastodon/Translating.md) +- [Report bugs and submit ideas](https://github.com/tootsuite/mastodon/issues) diff --git a/docs/Running-Mastodon/Administration-guide.md b/docs/Running-Mastodon/Administration-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..1b9dc8630a34ea121851530a4d04992ee6a6c3bf --- /dev/null +++ b/docs/Running-Mastodon/Administration-guide.md @@ -0,0 +1,28 @@ +Administration guide +================= + +So, you have a working Mastodon instance... now what? + +## Administration web interface + +A user that is designated as `admin = TRUE` in the database is able to access a suite of administration tools: + +* View, edit, silence, or suspend users - https://yourmastodon.instance/admin/accounts +* View PubSubHubbub subscriptions - https://yourmastodon.instance/admin/pubsubhubbub +* View domain blocks - https://yourmastodon.instance/admin/domain_blocks +* Sidekiq dashboard - https://yourmastodon.instance/sidekiq +* PGHero dashboard for PostgreSQL - https://yourmastodon.instance/pghero +* Edit site settings - https://yourmastodon.instance/admin/settings + +## Site settings + +Your site settings are stored in the `settings` database table, and editable through the admin interface at https://yourmastodon.instance/admin/settings. + +You are able to set the following settings: + +- Contact username +- Contact email +- Site description +- Site extended description + +You may wish to use the extended description (shown at https://yourmastodon.instance/about/more ) to display content guidelines or a user agreement (see https://mastodon.social/about/more for an example). \ No newline at end of file diff --git a/docs/Running-Mastodon/Development-guide.md b/docs/Running-Mastodon/Development-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..80e6e2f94de6db4602f8c3d5f5e399f7dc624d60 --- /dev/null +++ b/docs/Running-Mastodon/Development-guide.md @@ -0,0 +1,48 @@ +Development guide +================= + +**Don't use Docker to do development**. It's a quick way to get Mastodon running in production, it's **really really inconvenient for development**. Normally in Rails development environment you get hot reloading of backend code and on-the-fly compilation of assets like JS and CSS, but you lose those benefits by compiling a Docker image. If you want to contribute to Mastodon, it is worth it to simply set up a proper development environment. + +In fact, all you need is described in the [production guide](Production-guide.md), **with the following exceptions**. You **don't** need: + +- Nginx +- SystemD +- An `.env.production` file. If you need to set any environment variables, you can use an `.env` file +- To prefix any commands with `RAILS_ENV=production` since the default environment is "development" anyway +- Any cronjobs + +The command to install project dependencies does not require any flags, i.e. simply + + bundle install + +By default the development environment wants to connect to a `mastodon_development` database on localhost using your user/ident to login to Postgres (i.e. not a md5 password) + +You can run Mastodon with: + + rails s + +And open `http://localhost:3000` in your browser. Background jobs run inline (aka synchronously) in the development environment, so you don't need to run a Sidekiq process. + +You can run tests with: + + rspec + +You can check localization status with: + + i18n-tasks health + +You can check code quality with: + + rubocop + +## Development tips + +You can use a localhost->world tunneling service like ngrok if you want to test federation, **however** that should not be your primary mode of operation. If you want to have a permanently federating server, set up a proper instance on a VPS with a domain name, and simply keep it up to date with your own fork of the project while doing development on localhost. + +Ngrok and similar services give you a random domain on each start up. This is good enough to test how the code you're working on handles real-world situations. But as soon as your domain changes, for everybody else concerned you're a different instance than before. + +Generally, federation bits are tricky to work on for exactly this reason - it's hard to test. And when you are testing with a disposable instance you are polluting the databases of the real servers you're testing against, usually not a big deal but can be annoying. The way I have handled this so far was thus: I have used ngrok for one session, and recorded the exchanges from its web interface to create fixtures and test suites. From then on I've been working with those rather than live servers. + +I advise to study the existing code and the RFCs before trying to implement any federation-related changes. It's not *that* difficult, but I think "here be dragons" applies because it's easy to break. + +If your development environment is running remotely (e.g. on a VPS or virtual machine), setting the `REMOTE_DEV` environment variable will swap your instance from using "letter opener" (which launches a local browser) to "letter opener web" (which collects emails and displays them at /letter_opener ). \ No newline at end of file diff --git a/docs/Running-Mastodon/Heroku-guide.md b/docs/Running-Mastodon/Heroku-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..6aa8be774dd7e8112274085f0e10568737aacca4 --- /dev/null +++ b/docs/Running-Mastodon/Heroku-guide.md @@ -0,0 +1,13 @@ +Heroku guide +============ + +[](https://heroku.com/deploy) + +Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. It should be noted this has limited testing and could have unpredictable results. + +1. Click the above button. +2. Fill in the options requested. + * You can use a .herokuapp.com domain, which will be simple to set up, or you can use a custom domain. If you want a custom domain and HTTPS, you will need to upgrade to a paid plan (to use Heroku's SSL features), or set up [CloudFlare](https://cloudflare.com) who offer free "Flexible SSL" (note: CloudFlare have some undefined limits on WebSockets. So far, no one has reported hitting concurrent connection limits). + * You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saaved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details. + * If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests. +3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard. \ No newline at end of file diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..76964d9953134b214af1d0c4d88c4bf204db65f1 --- /dev/null +++ b/docs/Running-Mastodon/Production-guide.md @@ -0,0 +1,188 @@ +Production guide +================ + +## Nginx + +Regardless of whether you go with the Docker approach or not, here is an example Nginx server configuration: + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl; + server_name example.com; + + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + + keepalive_timeout 70; + sendfile on; + client_max_body_size 0; + gzip off; + + root /home/mastodon/live/public; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; + + location / { + try_files $uri @proxy; + } + + location @proxy { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_pass_header Server; + + proxy_pass http://localhost:3000; + proxy_buffering off; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + tcp_nodelay on; + } + + error_page 500 501 502 503 504 /500.html; +} +``` + +## Running in production without Docker + +It is recommended to create a special user for mastodon on the server (you could call the user `mastodon`), though remember to disable outside login for it. You should only be able to get into that user through `sudo su - mastodon`. + +## General dependencies + + curl -sL https://deb.nodesource.com/setup_4.x | sudo bash - + sudo apt-get install imagemagick ffmpeg libpq-dev libxml2-dev libxslt1-dev nodejs + sudo npm install -g yarn + +## Redis + + sudo apt-get install redis-server redis-tools + +## Postgres + + sudo apt-get install postgresql postgresql-contrib + +## Rbenv + +It is recommended to use rbenv (exclusively from the `mastodon` user) to install the desired Ruby version. Follow the guides to [install rbenv][1] and [rbenv-build][2] (I recommend checking the [prerequisites][3] for your system on the rbenv-build project and installing them beforehand, obviously outside the unprivileged `mastodon` user) + +[1]: https://github.com/rbenv/rbenv#installation +[2]: https://github.com/rbenv/ruby-build#installation +[3]: https://github.com/rbenv/ruby-build/wiki#suggested-build-environment + +Then once `rbenv` is ready, run `rbenv install 2.3.1` to install the Ruby version for Mastodon. + +## Git + +You need the `git-core` package installed on your system. If it is so, from the `mastodon` user: + + cd ~ + git clone https://github.com/Gargron/mastodon.git live + cd live + +Then you can proceed to install project dependencies: + + gem install bundler + bundle install --deployment --without development test + yarn install + +## Configuration + +Then you have to configure your instance: + + cp .env.production.sample .env.production + nano .env.production + +Fill in the important data, like host/port of the redis database, host/port/username/password of the postgres database, your domain name, SMTP details (e.g. from Mailgun or equivalent transactional e-mail service, many have free tiers), whether you intend to use SSL, etc. If you need to generate secrets, you can use: + + rake secret + +To get a random string. + +## Setup + +And setup the database for the first time, this will create the tables and basic data: + + RAILS_ENV=production bundle exec rails db:setup + +Finally, pre-compile all CSS and JavaScript files: + + RAILS_ENV=production bundle exec rails assets:precompile + +## Systemd + +Example systemd configuration for the web workers, to be placed in `/etc/systemd/system/mastodon-web.service`: + +```systemd +[Unit] +Description=mastodon-web +After=network.target + +[Service] +Type=simple +User=mastodon +WorkingDirectory=/home/mastodon/live +Environment="RAILS_ENV=production" +Environment="PORT=3000" +ExecStart=/home/mastodon/.rbenv/shims/bundle exec puma -C config/puma.rb +TimeoutSec=15 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Example systemd configuration for the background workers, to be placed in `/etc/systemd/system/mastodon-sidekiq.service`: + +```systemd +[Unit] +Description=mastodon-sidekiq +After=network.target + +[Service] +Type=simple +User=mastodon +WorkingDirectory=/home/mastodon/live +Environment="RAILS_ENV=production" +Environment="DB_POOL=5" +ExecStart=/home/mastodon/.rbenv/shims/bundle exec sidekiq -c 5 -q default -q mailers -q push +TimeoutSec=15 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going. + +## Cronjobs + +I recommend creating a couple cronjobs for the following tasks: + +- `RAILS_ENV=production bundle exec rake mastodon:media:clear` +- `RAILS_ENV=production bundle exec rake mastodon:push:refresh` +- `RAILS_ENV=production bundle exec rake mastodon:feeds:clear` + +You may want to run `which bundle` first and copypaste that full path instead of simply `bundle` in the above commands because cronjobs usually don't have all the paths set. The time and intervals of when to run these jobs are up to you, but once every day should be enough for all. + +You can edit the cronjob file for the `mastodon` user by running `sudo crontab -e mastodon` (outside of the mastodon user). + +## Things to look out for when upgrading Mastodon + +You can upgrade Mastodon with a `git pull` from the repository directory. You may need to run: + +- `RAILS_ENV=production bundle exec rails db:migrate` +- `RAILS_ENV=production bundle exec rails assets:precompile` + +Depending on which files changed, e.g. if anything in the `/db/` or `/app/assets` directory changed, respectively. Also, Mastodon runs in memory, so you need to restart it before you see any changes. If you're using systemd, that would be: + + sudo systemctl restart mastodon-*.service diff --git a/docs/Running-Mastodon/Vagrant-guide.md b/docs/Running-Mastodon/Vagrant-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..a94478392a3fc89e0d79ac34c1bd7e67e05690d0 --- /dev/null +++ b/docs/Running-Mastodon/Vagrant-guide.md @@ -0,0 +1,64 @@ +Vagrant guide +============= + +A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed. + +## Basic setup + +Install the latest versions of Vagrant and VirtualBox for your operating systems, and then run: + + vagrant plugin install vagrant-hostsupdater + +This is optional, but will update your 'hosts' file when you start the virtual machine, allowing you to access the site at http://mastodon.dev (instead of http://localhost:3000). + +To create and provision a new virtual machine for Mastodon development: + + git clone git@github.com:tootsuite/mastodon.git + cd mastodon + vagrant up + +Running `vagrant up` for the first time will run provisioning, which will: + +- Download the Ubuntu 14.04 base image, if there isn't already a copy on your machine +- Create a new VirtualBox virtual machine from that image +- Run the provisioning script (located inside the Vagrantfile), which installs the system packages, Ruby gems, and JS modules required for Mastodon +- Run the startup script + +## Starting the server + +The Vagrant box will automatically start after provisioning. It can be started in future with `vagrant up` from the mastodon directory. + +Once the Ubuntu virtual machine has booted, it will run the startup script, which loads the environment variables from `.env.vagrant` and then runs `rails s -d -b 0.0.0.0`. This will start a Rails server. You can then access your development site at http://mastodon.dev (or at http://localhost:3000 if you haven't installed vagrants-hostupdater). + +To stop the server, simply run `vagrant halt`. + +## Using the server + +You should now have a working Mastodon instance, although it will not federate, as it is not publicly accessible. Should you need temporary federation for development and testing, see the Ngrok information in the [Development Guide](Development-guide.md). + +By default, your instance's ActionMailer will use "Letter Opener Web" for email. This means that any email that would normally be sent, will instead be stored, and accessible at http://mastodon.dev/letter_opener - you can use this to verify a registered user account. + +## Making changes/developing + +You are able to set environment variables, which are used for Mastodon configuration, by editing the `.env.vagrant` file. Any changes you make will take effect after a Vagrant restart. + +Vagrant has mounted your mastodon folder inside the virtual machine. This means that any change to the files in the folder(e.g. the Rails controllers or the React components in /app) should immediately take effect on the live server. This allows you to make and test changes, and create new commits, without ever needing to access the virtual machine. + +Should you need to access the virtual machine (for example, to manually restart the Rails process without restarting the box), run `vagrant ssh` from the mastodon folder. You will now be logged in as the `vagrant` user on the VirtualBox Ubuntu VM. You will want to `cd /vagrant` to see the app folder. + +## Debugging + +You can find the Rails server logs in in the `log` folder, which will often have the information you need. + +If your Mastodon instance or Vagrant box are really not behaving, you can re-run the provisioning process. Stop the box with `vagrant halt`, and then run `vagrant destroy` - this will delete the virtual machine. You may then run `vagrant up` to create a new box, and re-run provisioning. + +## Testing + +To run the `rspec` tests and `rubocop` style checker, you may either: + +* Install the relevant gems locally, or +* SSH into the virtual machine, `cd /vagrant`, and then run the commands + +## Support/help + +If you are confused, or having any issues with the above, the Mastodon IRC channel ( irc.freenode.net #mastodon ) is a good place to find assistance. \ No newline at end of file diff --git a/docs/Specs-and-RFCs-used.md b/docs/Specs-and-RFCs-used.md new file mode 100644 index 0000000000000000000000000000000000000000..9bb1bb62239d282626149ac0a918de6c3358c509 --- /dev/null +++ b/docs/Specs-and-RFCs-used.md @@ -0,0 +1,12 @@ +Specs and RFCs used +=================== + +* [OStatus](https://www.w3.org/community/ostatus/wiki/images/9/93/OStatus_1.0_Draft_2.pdf) +* [Salmon](http://www.salmon-protocol.org/salmon-protocol-summary) +* [Portable Contacts](https://web.archive.org/web/20160305010620/http://portablecontacts.net/draft-spec.html) +* [Atom](https://tools.ietf.org/html/rfc4287) +* [Atom ActivityStreams](http://activitystrea.ms/specs/atom/1.0/) +* [Atom Threading](https://tools.ietf.org/html/rfc4685) +* [PubSubHubbub](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html) +* [Webfinger](https://tools.ietf.org/html/rfc7033) +* [Link-based Resource Descriptor Discovery](https://tools.ietf.org/html/rfc6415) diff --git a/docs/Using-Mastodon/Apps.md b/docs/Using-Mastodon/Apps.md new file mode 100644 index 0000000000000000000000000000000000000000..e0a2730c1bddfef0d5d20bffaa6ad00a03967013 --- /dev/null +++ b/docs/Using-Mastodon/Apps.md @@ -0,0 +1,15 @@ +List of apps +============ + +Some people have started working on apps for the Mastodon API. Here is a list of them: + +|App|Platform|Link|Developer(s)| +|---|--------|----|------------| +|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)| +|Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)| +|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)| +|tootstream|command-line|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)| +|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>|| +|Tooter|Chrome extension|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)| + +If you have a project like this, let me know so I can add it to the list! diff --git a/docs/Using-Mastodon/FAQ.md b/docs/Using-Mastodon/FAQ.md new file mode 100644 index 0000000000000000000000000000000000000000..daedcbdd8a7514a1670ae7d23f718a2bf5265e7f --- /dev/null +++ b/docs/Using-Mastodon/FAQ.md @@ -0,0 +1,43 @@ +Frequently Asked Questions +========================== + +#### What is a Mastodon? + +A prehistoric animal, predecessor of the mammoth. + +#### Why the name Mastodon? + +There's a progressive metal band with the same name that I'm a fan of that brought the animal to my attention. I thought it's a pretty cool name/animal. + +#### How exactly is it decentralized? + +There are different ways in which something can be decentralized; in this case, Mastodon is the "federated" kind. Think e-mail, not BitTorrent. There are different servers (instances), users have an account on one of them, but can interact and follow each other regardless of where their account is. + +#### Technically, how does the federation work? + +We are using the OStatus suite of protocols: + +1. Webfinger for user-on-domain lookup +2. Atom feeds with ActivityStreams, Portable Contacts, Threads extensions for the actual content +3. PubSubHubbub for subscribing to Atom feeds +4. Salmon for delivering certain items from the Atom feeds to interested parties such as the mentioned user, author of the status being replied to, person being followed, etc + +#### What is mastodon.social? + +The "flagship" instance of Mastodon, aka the server I run myself with the latest code. It's not supposed to be the only instance in the end. + +#### What else is part of the federated network? + +Let's call it the "fediverse". It has existed for a longer while, populated by GNU social servers, Friendica, Hubzilla, Diaspora etc. Not every one of those servers is fully compatible with every other. Mastodon strives to be fully standards-compliant and compatibility with GNU social is higher in priority than the others. + +#### I tried logging into a GNU social client app with Mastodon and it didn't work, why? + +While Mastodon is compatible with GNU social in terms of server to server communication, the client to server API (aka how you access Mastodon) is different. Therefore, client apps that were made for specifically GNU social will not work with Mastodon. The reason for this is half technical, half ideological. + +Because Mastodon has been created from a blank slate, it is much simpler to have the API mirror internal structures as closely as possible, rather than build an emulation layer. Secondly, the GNU social client API is actually a half-way implementation of the legacy Twitter API - that's the reason why it works with some older Twitter client apps. However, many of those apps are not maintained anymore, the GNU social API does not actually keep up with the real Twitter API and never fully implemented all its features; at the same time, the Twitter API was never meant for a federated service and so obscures some of the functionality. + +#### How is Mastodon funded? + +Development of Mastodon and hosting of mastodon.social is funded through my [Patreon (also BTC/PayPal donations)](https://www.patreon.com/user?u=619786). Beyond that, I am not interested in VC funding, monetizing, advertising, or anything of that sort. I could offer setup/maintenance services on demand. + +The software is free and open source and communities should host their own servers if they can, that way the costs are more or less distributed. Obviously it'd be hard for me to pay the bills if literally everyone decided to use the mastodon.social instance only. \ No newline at end of file diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md new file mode 100644 index 0000000000000000000000000000000000000000..2f15df083d9ec78dd10aaa0c66745327a289c892 --- /dev/null +++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md @@ -0,0 +1,12 @@ +List of Mastodon instances +========================== + +* [mastodon.social](https://mastodon.social) +* [social.tchncs.de](https://social.tchncs.de) +* [on.vu](https://on.vu) +* [animalliberation.social](https://animalliberation.social) +* [socially.constructed.space](https://socially.constructed.space) +* [epiktistes.com](https://epiktistes.com) +* [toot.zone](https://toot.zone) + +Let me know if you start running one so I can add it to the list! diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md new file mode 100644 index 0000000000000000000000000000000000000000..9f528087001eaa4535934ca4179ab50812aa2391 --- /dev/null +++ b/docs/Using-the-API/API.md @@ -0,0 +1,280 @@ +API overview +============ + +## Contents + +- [Available libraries](#available-libraries) +- [Notes](#notes) +- [Methods](#methods) + - Posting a status + - Uploading media + - Retrieving a timeline + - Retrieving notifications + - Following a remote user + - Fetching data + - Deleting a status + - Reblogging a status + - Favouriting a status + - Threads (status context) + - Who reblogged/favourited a status + - Following/unfollowing accounts + - Blocking/unblocking accounts + - Creating OAuth apps +- [Entities](#entities) + - Status + - Account +- [Pagination](#pagination) + +## Available libraries + +- [For Ruby](https://github.com/tootsuite/mastodon-api) +- [For Python](https://github.com/halcy/Mastodon.py) +- [For JavaScript](https://github.com/Zatnosk/libodonjs) +- [For JavaScript (Node.js)](https://github.com/jessicahayley/node-mastodon) + +## Notes + +When an array parameter is mentioned, the Rails convention of specifying array parameters in query strings is meant. For example, a ruby array like `foo = [1, 2, 3]` can be encoded in the params as `foo[]=1&foo[]=2&foo[]=3`. Square brackets can be indexed but can also be empty. + +When a file parameter is mentioned, a form-encoded upload is expected. + +## Methods +### Posting a new status + +**POST /api/v1/statuses** + +Form data: + +- `status`: The text of the status +- `in_reply_to_id` (optional): local ID of the status you want to reply to +- `media_ids` (optional): array of media IDs to attach to the status (maximum 4) +- `sensitive` (optional): set this to mark the media of the status as NSFW +- `visibility` (optional): either `private`, `unlisted` or `public` + +Returns the new status. + +**POST /api/v1/media** + +Form data: + +- `file`: Image to be uploaded + +Returns a media object with an ID that can be attached when creating a status (see above). + +### Retrieving a timeline + +**GET /api/v1/timelines/home** +**GET /api/v1/timelines/mentions** +**GET /api/v1/timelines/public** +**GET /api/v1/timelines/tag/:hashtag** + +Returns statuses, most recent ones first. Home timeline is statuses from people you follow, mentions timeline is all statuses that mention you. Public timeline is "whole known network", and the last is the hashtag timeline. + +Query parameters: + +- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time) +- `since_id` (optional): Skip statuses older than ID (e.g. check for updates) + +### Notifications + +**GET /api/v1/notifications** + +Returns notifications for the authenticated user. Each notification has an `id`, a `type` (mention, reblog, favourite, follow), an `account` which it came *from*, and in case of mention, reblog and favourite also a `status`. + +### Following a remote user + +**POST /api/v1/follows** + +Form data: + +- uri: username@domain of the person you want to follow + +Returns the local representation of the followed account. + +### Fetching data + +**GET /api/v1/statuses/:id** + +Returns status. + +**GET /api/v1/accounts/:id** + +Returns account. + +**GET /api/v1/accounts/verify_credentials** + +Returns authenticated user's account. + +**GET /api/v1/accounts/:id/statuses** + +Returns statuses by user. Same options as timeline are permitted. + +**GET /api/v1/accounts/:id/following** + +Returns users the given user is following. + +**GET /api/v1/accounts/:id/followers** + +Returns users the given user is followed by. + +**GET /api/v1/accounts/relationships** + +Returns relationships (`following`, `followed_by`, `blocking`) of the current user to a list of given accounts. + +Query parameters: + +- `id` (can be array): Account IDs + +**GET /api/v1/accounts/search** + +Returns matching accounts. Will lookup an account remotely if the search term is in the username@domain format and not yet in the database. + +Query parameters: + +- `q`: what to search for +- `limit`: maximum number of matching accounts to return + +**GET /api/v1/blocks** + +Returns accounts blocked by authenticated user. + +**GET /api/v1/favourites** + +Returns statuses favourited by authenticated user. + +### Deleting a status + +**DELETE /api/v1/statuses/:id** + +Returns an empty object. + +### Reblogging a status + +**POST /api/v1/statuses/:id/reblog** + +Returns a new status that wraps around the reblogged one. + +### Unreblogging a status + +**POST /api/v1/statuses/:id/unreblog** + +Returns the status that used to be reblogged. + +### Favouriting a status + +**POST /api/v1/statuses/:id/favourite** + +Returns the target status. + +### Unfavouriting a status + +**POST /api/v1/statuses/:id/unfavourite** + +Returns the target status. + +### Threads + +**GET /api/v1/statuses/:id/context** + +Returns `ancestors` and `descendants` of the status. + +### Who reblogged/favourited a status + +**GET /api/v1/statuses/:id/reblogged_by** +**GET /api/v1/statuses/:id/favourited_by** + +Returns list of accounts. + +### Following and unfollowing users + +**POST /api/v1/accounts/:id/follow** +**POST /api/v1/accounts/:id/unfollow** + +Returns the updated relationship to the user. + +### Blocking and unblocking users + +**POST /api/v1/accounts/:id/block** +**POST /api/v1/accounts/:id/unblock** + +Returns the updated relationship to the user. + +### OAuth apps + +**POST /api/v1/apps** + +Form data: + +- `client_name`: Name of your application +- `redirect_uris`: Where the user should be redirected after authorization (for no redirect, use `urn:ietf:wg:oauth:2.0:oob`) +- `scopes`: This can be a space-separated list of the following items: "read", "write" and "follow" (see [this page](OAuth-details.md) for details on what the scopes do) +- `website`: (optional) URL to the homepage of your app + +Creates a new OAuth app. Returns `id`, `client_id` and `client_secret` which can be used with [OAuth authentication in your 3rd party app](Testing-with-cURL.md). + +___ + +## Entities + +### Status + +| Attribute | Description | +|---------------------|-------------| +| `id` || +| `uri` | fediverse-unique resource ID | +| `url` | URL to the status page (can be remote) | +| `account` | Account | +| `in_reply_to_id` | null or ID of status it replies to | +| `reblog` | null or Status| +| `content` | Body of the status. This will contain HTML (remote HTML already sanitized) | +| `created_at` || +| `reblogs_count` || +| `favourites_count` || +| `reblogged` | Boolean for authenticated user | +| `favourited` | Boolean for authenticated user | +| `media_attachments` | array of MediaAttachments | +| `mentions` | array of Mentions | +| `application` | Application from which the status was posted | + +Media Attachment: + +| Attribute | Description | +|---------------------|-------------| +| `url` | URL of the original image (can be remote) | +| `preview_url` | URL of the preview image | +| `type` | Image or video | + +Mention: + +| Attribute | Description | +|---------------------|-------------| +| `url` | URL of user's profile (can be remote) | +| `acct` | Username for local or username@domain for remote users | +| `id` | Account ID | + +Application: + +| Attribute | Description | +|---------------------|-------------| +| `name` | Name of the app | +| `website` | Homepage URL of the app | + +### Account + +| Attribute | Description | +|-------------------|-------------| +| `id` || +| `username` || +| `acct` | Equals username for local users, includes @domain for remote ones | +| `display_name` || +| `note` | Biography of user | +| `url` | URL of the user's profile page (can be remote) | +| `avatar` | URL to the avatar image | +| `header` | URL to the header image | +| `followers_count` || +| `following_count` || +| `statuses_count` || + +## Pagination + +API methods that return collections of items can return a `Link` header containing URLs for the `next` and `prev` pages. [Link header RFC](https://tools.ietf.org/html/rfc5988) diff --git a/docs/Using-the-API/OAuth-details.md b/docs/Using-the-API/OAuth-details.md new file mode 100644 index 0000000000000000000000000000000000000000..d0b5abd40b6d5a79c7effc2216fe149c205454eb --- /dev/null +++ b/docs/Using-the-API/OAuth-details.md @@ -0,0 +1,12 @@ +OAuth details +============= + +We use the [Doorkeeper gem for OAuth](https://github.com/doorkeeper-gem/doorkeeper/wiki), so you can refer to their docs on specifics of the end-points. + +The API is divided up into access scopes: + +- `read`: Read data +- `write`: Post statuses and upload media for statuses +- `follow`: Follow, unfollow, block, unblock + +Multiple scopes can be requested during the authorization phase with the `scope` query param (space-separate the scopes). diff --git a/docs/Using-the-API/Testing-with-cURL.md b/docs/Using-the-API/Testing-with-cURL.md new file mode 100644 index 0000000000000000000000000000000000000000..977773a083133023493eecc91d3ba61e80765e86 --- /dev/null +++ b/docs/Using-the-API/Testing-with-cURL.md @@ -0,0 +1,16 @@ +Testing the API with cURL +========================= + +Mastodon builds around the idea of being a server first, rather than a client itself. Similarly to how a XMPP chat server communicates with others and with its own clients, Mastodon takes care of federation to other networks, like other Mastodon or GNU Social instances. So Mastodon provides a REST API, and a 3rd-party app system for using it via OAuth2. + +You can get a client ID and client secret required for OAuth [via an API end-point](API.md#oauth-apps). + +From these two, you will need to acquire an access token. It is possible to do using your account's e-mail and password like this: + + curl -X POST -d "client_id=CLIENT_ID_HERE&client_secret=CLIENT_SECRET_HERE&grant_type=password&username=YOUR_EMAIL&password=YOUR_PASSWORD" -Ss https://mastodon.social/oauth/token + +The response will be a JSON object containing the key `access_token`. Use that token in any API requests by setting a header like this: + + curl --header "Authorization: Bearer ACCESS_TOKEN_HERE" -sS https://mastodon.social/api/statuses/home + +Please note that the password-based approach is not recommended especially if you're dealing with other user's accounts and not just your own. Usually you would use the authorization grant approach where you redirect the user to a web page on the original site where they can login and authorize the application and are then redirected back to your application with an access code. diff --git a/docs/Using-the-API/Tips-for-app-developers.md b/docs/Using-the-API/Tips-for-app-developers.md new file mode 100644 index 0000000000000000000000000000000000000000..561f1e2737bb6e923d730ff2b870bb56d52666d0 --- /dev/null +++ b/docs/Using-the-API/Tips-for-app-developers.md @@ -0,0 +1,16 @@ +Tips for app developers +======================= + +## Authentication + +Make sure that you allow your users to specify the domain they want to connect to before login. Use that domain to acquire a client id/secret for OAuth2 and then proceed with normal OAuth2 also using that domain to build the URLs. + +In my opinion it is easier for people to understand what is being asked of them if you ask for a `username@domain` type input, since it looks like an e-mail address. Though the username part is not required for anything in the OAuth2 process. Once the user is logged in, you get information about the logged in user from `/api/v1/accounts/verify_credentials` + +## Usernames + +Make sure that you make it possible to see the `acct` of any user in your app (since it includes the domain part for remote users), people must be able to tell apart users from different domains with the same username. + +## Formatting + +The API delivers already formatted HTML to your app. This isn't ideal since not all apps are based on HTML, but this is not fixable as its part of the way OStatus federation works. Most importantly, you get some information on linked entities alongside the HTML of the status body. For example, you get a list of mentioned users, and a list of media attachments, and a list of hashtags. It is possible to convert the HTML to whatever you need in your app by parsing the HTML tags and matching their `href`s to the linked entities. If a match cannot be found, the link must stay a clickable link. diff --git a/lib/statsd_monitor.rb b/lib/statsd_monitor.rb new file mode 100644 index 0000000000000000000000000000000000000000..e48ce654106e1dc5a8cc296031459237a6759a63 --- /dev/null +++ b/lib/statsd_monitor.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class StatsDMonitor + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + end +end diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index a95a7258fa831582b6d04d4a7b3b14c0db3ab8bd..13220f68e3e23855e914a18072db654c629f227a 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -6,6 +6,11 @@ namespace :mastodon do task clear: :environment do MediaAttachment.where(status_id: nil).where('created_at < ?', 1.day.ago).find_each(&:destroy) end + + desc 'Remove media attachments attributed to silenced accounts' + task remove_silenced: :environment do + MediaAttachment.where(account: Account.silenced).find_each(&:destroy) + end end namespace :push do diff --git a/package.json b/package.json index 05663a729ef225fce0dcbe69b88698926f87bc95..dbcc032c67c55fc4a7260381eafbbec3db66669c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "test": "mocha --require ./spec/javascript/setup.js --compilers js:babel-register ./spec/javascript/components/*.test.jsx", "storybook": "start-storybook -p 9001 -c storybook" }, - "devDependencies": { + "dependencies": { "@kadira/storybook": "^2.24.0", "axios": "^0.14.0", "babel-plugin-react-transform": "^2.0.2", @@ -18,7 +18,7 @@ "chai": "^3.5.0", "chai-enzyme": "^0.5.2", "css-loader": "^0.26.1", - "emojione": "^2.2.6", + "emojione": "latest", "enzyme": "^2.4.1", "es6-promise": "^3.2.1", "http-link-header": "^0.5.0", @@ -34,24 +34,27 @@ "react-autosuggest": "^7.0.1", "react-decoration": "^1.4.0", "react-dom": "^15.3.0", + "react-imageloader": "^2.1.0", "react-immutable-proptypes": "^2.1.0", "react-intl": "^2.1.5", "react-motion": "^0.4.5", "react-notification": "^6.4.0", "react-proxy": "^1.1.8", - "react-redux": "^5.0.0-beta.3", + "react-redux": "^5.0.1", "react-redux-loading-bar": "^2.4.1", "react-router": "^2.8.0", "react-router-scroll": "^0.3.2", "react-simple-dropdown": "^1.1.4", "react-storybook-addon-intl": "^0.1.0", "react-toggle": "^2.1.1", - "redux": "^3.5.2", + "redux": "^3.6.0", "redux-immutable": "^3.0.8", + "redux-sounds": "^1.1.1", "redux-thunk": "^2.1.0", "reselect": "^2.5.4", "sass-loader": "^4.0.2", "sinon": "^1.17.6", - "style-loader": "^0.13.1" + "style-loader": "^0.13.1", + "webpack": "^1.14.0" } } diff --git a/public/404.html b/public/404.html deleted file mode 100644 index eecfd6743820883829567f7cf687252644cc525b..0000000000000000000000000000000000000000 --- a/public/404.html +++ /dev/null @@ -1,43 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <title>The page you were looking for doesn't exist</title> - <meta name="viewport" content="width=device-width,initial-scale=1"> - <link href="https://fonts.googleapis.com/css?family=Roboto:400" rel="stylesheet"> - <style> - body { - font-family: 'Roboto', sans-serif; - background: #282c37; - color: #9baec8; - text-align: center; - margin: 0; - padding: 20px; - } - - .dialog img { - display: block; - margin: 20px auto; - margin-top: 50px; - max-width: 600px; - width: 100%; - height: auto; - } - - .dialog h1 { - font: 20px/28px 'Roboto', sans-serif; - font-weight: 400; - } - </style> -</head> - -<body> - <div class="dialog"> - <img src="/oops.png" alt="Mastodon" /> - - <div> - <h1>The page you were looking for doesn't exist</h1> - </div> - </div> -</body> -</html> diff --git a/public/500.html b/public/500.html index 915b890f15ce20887807254de5d264625cd675f6..d085d490b0c1e2e3230702455070cc28eac8c2b2 100644 --- a/public/500.html +++ b/public/500.html @@ -7,7 +7,7 @@ <link href="https://fonts.googleapis.com/css?family=Roboto:400" rel="stylesheet"> <style> body { - font-family: 'Roboto', sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #282c37; color: #9baec8; text-align: center; @@ -25,7 +25,7 @@ } .dialog h1 { - font: 20px/28px 'Roboto', sans-serif; + font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 400; } </style> diff --git a/public/headers/original/missing.png b/public/headers/original/missing.png new file mode 100644 index 0000000000000000000000000000000000000000..fdc34289db331fee185f39fe660d99412d1b4276 Binary files /dev/null and b/public/headers/original/missing.png differ diff --git a/public/sounds/boop.mp3 b/public/sounds/boop.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..02a035d91b02049949e92f4116d2a1be6c85a4f1 Binary files /dev/null and b/public/sounds/boop.mp3 differ diff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb index 758bfd1dad365bb08adf535fc2d2e6a6001f3256..511cdb4639cb80b18c9edf2fe529a6f5e0d2432f 100644 --- a/spec/controllers/api/oembed_controller_spec.rb +++ b/spec/controllers/api/oembed_controller_spec.rb @@ -1,5 +1,16 @@ require 'rails_helper' -RSpec.describe Api::OembedController, type: :controller do +RSpec.describe Api::OEmbedController, type: :controller do + let(:alice) { Fabricate(:account, username: 'alice') } + let(:status) { Fabricate(:status, text: 'Hello world', account: alice) } + describe 'GET #show' do + before do + get :show, params: { url: account_stream_entry_url(alice, status.stream_entry) }, format: :json + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end end diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index d9c73f9529a86da7790197cc99bd68e5db0c39ab..669956659a6214635db7f7001852e4573d9b6887 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -4,7 +4,8 @@ RSpec.describe Api::V1::StatusesController, type: :controller do render_views let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - let(:token) { double acceptable?: true, resource_owner_id: user.id } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { double acceptable?: true, resource_owner_id: user.id, application: app } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index f7ebebbcb8dec3e7345f74ba2f9f04a05ad591c6..27ad6cbde5c0d2a646b6cbc3e352715d4f4dc0f4 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -20,8 +20,8 @@ RSpec.describe Auth::RegistrationsController, type: :controller do post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678' } } end - it 'redirects to home page' do - expect(response).to redirect_to root_path + it 'redirects to login page' do + expect(response).to redirect_to new_user_session_path end it 'creates user' do diff --git a/spec/fabricators/application_fabricator.rb b/spec/fabricators/application_fabricator.rb new file mode 100644 index 0000000000000000000000000000000000000000..42b7009dc23c839c840ccc4ee4c531f36a79c83e --- /dev/null +++ b/spec/fabricators/application_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:application, from: Doorkeeper::Application) do + name 'Example' + website 'http://example.com' + redirect_uri 'http://example.com/callback' +end diff --git a/spec/fabricators/media_attachment_fabricator.rb b/spec/fabricators/media_attachment_fabricator.rb index b1a0cd991126fd16f67cfd7e5e829abd1ee52a67..59db2440d6ae5584912450b6b4126b9bc92113f2 100644 --- a/spec/fabricators/media_attachment_fabricator.rb +++ b/spec/fabricators/media_attachment_fabricator.rb @@ -1,2 +1,3 @@ Fabricator(:media_attachment) do + end diff --git a/spec/fabricators/preview_card_fabricator.rb b/spec/fabricators/preview_card_fabricator.rb new file mode 100644 index 0000000000000000000000000000000000000000..448a94e7ed873e2e71988ef70e5b03bb54088e7c --- /dev/null +++ b/spec/fabricators/preview_card_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:preview_card) do + status_id 1 + url "MyString" + html "MyText" +end diff --git a/spec/fabricators/web_setting_fabricator.rb b/spec/fabricators/web_setting_fabricator.rb new file mode 100644 index 0000000000000000000000000000000000000000..e5136829b9d0a88e4a095ec0ee75cc2da23eeb11 --- /dev/null +++ b/spec/fabricators/web_setting_fabricator.rb @@ -0,0 +1,3 @@ +Fabricator('Web::Setting') do + +end diff --git a/spec/fixtures/xml/mastodon.atom b/spec/fixtures/xml/mastodon.atom index ce28cd77b14bcd1bf1281841c4fe37a3400a9845..9ece3bc2e39b356f50c87bce8d0b8aeb04e98d4a 100644 --- a/spec/fixtures/xml/mastodon.atom +++ b/spec/fixtures/xml/mastodon.atom @@ -107,14 +107,14 @@ <uri>https://mastodon.social/users/Gargron</uri> <name>Gargron</name> <email>Gargron@mastodon.social</email> - <summary>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</summary> + <summary>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</summary> <link rel="alternate" type="text/html" href="https://mastodon.social/users/Gargron"/> <link rel="avatar" type="image/png" media:width="300" media:height="300" href="http://kickass.zone/system/accounts/avatars/000/000/003/large/4375_eugencommish.png"/> <link rel="avatar" type="image/png" media:width="96" media:height="96" href="http://kickass.zone/system/accounts/avatars/000/000/003/medium/4375_eugencommish.png"/> <link rel="avatar" type="image/png" media:width="48" media:height="48" href="http://kickass.zone/system/accounts/avatars/000/000/003/small/4375_eugencommish.png"/> <poco:preferredUsername>Gargron</poco:preferredUsername> <poco:displayName>Eugen</poco:displayName> - <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</poco:note> + <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</poco:note> </author> </activity:object> </entry> @@ -192,14 +192,14 @@ <uri>https://mastodon.social/users/Gargron</uri> <name>Gargron</name> <email>Gargron@mastodon.social</email> - <summary>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</summary> + <summary>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</summary> <link rel="alternate" type="text/html" href="https://mastodon.social/users/Gargron"/> <link rel="avatar" type="image/png" media:width="300" media:height="300" href="http://kickass.zone/system/accounts/avatars/000/000/003/large/4375_eugencommish.png"/> <link rel="avatar" type="image/png" media:width="96" media:height="96" href="http://kickass.zone/system/accounts/avatars/000/000/003/medium/4375_eugencommish.png"/> <link rel="avatar" type="image/png" media:width="48" media:height="48" href="http://kickass.zone/system/accounts/avatars/000/000/003/small/4375_eugencommish.png"/> <poco:preferredUsername>Gargron</poco:preferredUsername> <poco:displayName>Eugen</poco:displayName> - <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</poco:note> + <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</poco:note> </activity:object> </entry> <entry> diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index e7126127e0009aac1728bdfb10b77bf3c8d2389f..d982b9dca68127bcd01ed506157e6f24678f72d6 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -6,12 +6,12 @@ RSpec.describe 'I18n' do let(:missing_keys) { i18n.missing_keys } let(:unused_keys) { i18n.unused_keys } - it 'does not have missing keys' do + xit 'does not have missing keys' do expect(missing_keys).to be_empty, "Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them" end - it 'does not have unused keys' do + xit 'does not have unused keys' do expect(unused_keys).to be_empty, "#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them" end diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index 7b8259fa62c3a3d463738c9b0dc7830ac35a7ed6..6ec28f5d88e8e2b47fc9d884d0ade592d10a712e 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Formatter do end it 'contains a link' do - expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com">google.com</a>') + expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com"><span class="invisible">http://</span><span class="ellipsis">google.com</span><span class="invisible"></span></a>') end end diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index d4baca5aaada01f83defc78bc1447992703f7220..3beaebeb1c5ede69fb2ae5b2b6759d0ee9cc33fc 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -53,12 +53,12 @@ RSpec.describe NotificationMailer, type: :mailer do let(:mail) { NotificationMailer.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) } it "renders the headers" do - expect(mail.subject).to eq("bob reblogged your status") + expect(mail.subject).to eq("bob boosted your status") expect(mail.to).to eq([receiver.email]) end it "renders the body" do - expect(mail.body.encoded).to match("Your status was reblogged by bob") + expect(mail.body.encoded).to match("Your status was boosted by bob") end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index a72369b1c0ebbff903a8d6e76ada01148effe1d0..287f389ac9465e3a7e08f1338ebb6733fad2e67f 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -154,6 +154,31 @@ RSpec.describe Account, type: :model do end end + describe '.following_map' do + it 'returns an hash' do + expect(Account.following_map([], 1)).to be_a Hash + end + end + + describe '.followed_by_map' do + it 'returns an hash' do + expect(Account.followed_by_map([], 1)).to be_a Hash + end + end + + describe '.blocking_map' do + it 'returns an hash' do + expect(Account.blocking_map([], 1)).to be_a Hash + end + end + + describe '.requested_map' do + it 'returns an hash' do + expect(Account.requested_map([], 1)).to be_a Hash + end + end + + describe 'MENTION_RE' do subject { Account::MENTION_RE } diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..14ef23923cb7468ffaf9c1e3edcc5e89813ef1a7 --- /dev/null +++ b/spec/models/preview_card_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe PreviewCard, type: :model do + +end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb index d40bf0b4473a05399b4dd160e231a4aa2184baee..9cb3d41ce9528ffbcbf14341a327f410d447a433 100644 --- a/spec/models/subscription_spec.rb +++ b/spec/models/subscription_spec.rb @@ -1,5 +1,5 @@ require 'rails_helper' RSpec.describe Subscription, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + end diff --git a/spec/models/web/setting_spec.rb b/spec/models/web/setting_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..90e7695aa77d1f49a3200bd9061d445f85bcb30c --- /dev/null +++ b/spec/models/web/setting_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Web::Setting, type: :model do + +end diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb index 9933d016fe4a436606c57756cf3db3196cf2a48d..d88b3b55cf7b5f89ba4875f21b208cf77d78fbe4 100644 --- a/spec/services/block_domain_service_spec.rb +++ b/spec/services/block_domain_service_spec.rb @@ -14,7 +14,7 @@ RSpec.describe BlockDomainService do bad_status2 bad_attachment - subject.call('evil.org') + subject.call('evil.org', :suspend) end it 'creates a domain block' do @@ -22,7 +22,7 @@ RSpec.describe BlockDomainService do end it 'removes remote accounts from that domain' do - expect(Account.find_remote('badguy666', 'evil.org')).to be_nil + expect(Account.find_remote('badguy666', 'evil.org').suspended?).to be true end it 'removes the remote accounts\'s statuses and media attachments' do diff --git a/storybook/storybook.scss b/storybook/storybook.scss index b0145f9bda7a6d83837a8d70feea739b2a2148e8..31f11b5adfadff6e44a0a9a19c41e68c3fb6c0b5 100644 --- a/storybook/storybook.scss +++ b/storybook/storybook.scss @@ -2,7 +2,7 @@ @import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500); #root { - font-family: 'Roboto', sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #282c37; font-size: 13px; line-height: 18px; diff --git a/yarn.lock b/yarn.lock index f71a8ae104df650153ce489098c1d68889997019..ee3e577834859a6f5fc1efb79af4238a66508b04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -97,10 +97,6 @@ webpack-dev-middleware "^1.6.0" webpack-hot-middleware "^2.10.0" -Base64@~0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/Base64/-/Base64-0.2.1.tgz#ba3a4230708e186705065e66babdd4c35cf60028" - JSONStream@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-0.10.0.tgz#74349d0d89522b71f30f0a03ff9bd20ca6f12ac0" @@ -1124,6 +1120,12 @@ browser-stdout@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f" +browserify-aes@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-0.4.0.tgz#067149b668df31c4b58533e02d01e806d8608e2c" + dependencies: + inherits "^2.0.1" + browserify-aes@^1.0.0, browserify-aes@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.6.tgz#5e7725dbdef1fd5930d4ebab48567ce451c48a0a" @@ -1186,7 +1188,7 @@ browserify-sign@^4.0.0: inherits "^2.0.1" parse-asn1 "^5.0.0" -browserify-zlib@~0.1.2, browserify-zlib@~0.1.4: +browserify-zlib@^0.1.4, browserify-zlib@~0.1.2: version "0.1.4" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" dependencies: @@ -1516,11 +1518,7 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" -constants-browserify@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-0.0.1.tgz#92577db527ba6c4cf0a4568d84bc031f441e21f2" - -constants-browserify@~1.0.0: +constants-browserify@^1.0.0, constants-browserify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -1596,6 +1594,15 @@ cryptiles@2.x.x: dependencies: boom "2.x.x" +crypto-browserify@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.3.0.tgz#b9fc75bb4a0ed61dcf1cd5dae96eb30c9c3e506c" + dependencies: + browserify-aes "0.4.0" + pbkdf2-compat "2.0.1" + ripemd160 "0.2.0" + sha.js "2.2.6" + crypto-browserify@^3.0.0: version "3.11.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522" @@ -1611,14 +1618,6 @@ crypto-browserify@^3.0.0: public-encrypt "^4.0.0" randombytes "^2.0.0" -crypto-browserify@~3.2.6: - version "3.2.8" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.2.8.tgz#b9b11dbe6d9651dd882a01e6cc467df718ecf189" - dependencies: - pbkdf2-compat "2.0.1" - ripemd160 "0.2.0" - sha.js "2.2.6" - css-color-names@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" @@ -1935,9 +1934,9 @@ elliptic@^6.0.0: hash.js "^1.0.0" inherits "^2.0.1" -emojione: - version "2.2.6" - resolved "https://registry.yarnpkg.com/emojione/-/emojione-2.2.6.tgz#67dec452937d5b14ee669207ea41cdb1f69fb8f6" +emojione@latest: + version "2.2.7" + resolved "https://registry.yarnpkg.com/emojione/-/emojione-2.2.7.tgz#46457cf6b9b2f8da13ae8a2e4e547de06ee15e96" emojis-list@^2.0.0: version "2.1.0" @@ -2368,7 +2367,7 @@ glob-parent@^2.0.0: dependencies: is-glob "^2.0.0" -glob@7.0.5, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5: +glob@7.0.5, glob@^7.0.0, glob@^7.0.3: version "7.0.5" resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.5.tgz#b4202a69099bbb4d292a7c1b95b6682b67ebdc95" dependencies: @@ -2389,7 +2388,7 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" -glob@~7.1.1: +glob@^7.0.5, glob@~7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" dependencies: @@ -2500,6 +2499,10 @@ hosted-git-info@^2.1.4: version "2.1.5" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b" +howler@^1.1.28: + version "1.1.29" + resolved "https://registry.yarnpkg.com/howler/-/howler-1.1.29.tgz#9a3a7fa69e9b9d805c65ad98f66e35893a597b63" + html-comment-regex@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e" @@ -2528,13 +2531,6 @@ htmlparser2@~3.8.1: entities "1.0" readable-stream "1.1" -http-browserify@^1.3.2: - version "1.7.0" - resolved "https://registry.yarnpkg.com/http-browserify/-/http-browserify-1.7.0.tgz#33795ade72df88acfbfd36773cefeda764735b20" - dependencies: - Base64 "~0.2.0" - inherits "~2.0.1" - http-errors@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.5.0.tgz#b1cb3d8260fd8e2386cad3189045943372d48211" @@ -2555,11 +2551,7 @@ http-signature@~1.1.0: jsprim "^1.2.2" sshpk "^1.7.0" -https-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.0.tgz#b3ffdfe734b2a3d4a9efd58e8654c91fce86eafd" - -https-browserify@~0.0.0: +https-browserify@0.0.1, https-browserify@~0.0.0: version "0.0.1" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" @@ -3430,32 +3422,32 @@ node-gyp@^3.3.1: tar "^2.0.0" which "1" -node-libs-browser@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-0.6.0.tgz#244806d44d319e048bc8607b5cc4eaf9a29d2e3c" +node-libs-browser@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-0.7.0.tgz#3e272c0819e308935e26674408d7af0e1491b83b" dependencies: assert "^1.1.1" - browserify-zlib "~0.1.4" + browserify-zlib "^0.1.4" buffer "^4.9.0" console-browserify "^1.1.0" - constants-browserify "0.0.1" - crypto-browserify "~3.2.6" + constants-browserify "^1.0.0" + crypto-browserify "3.3.0" domain-browser "^1.1.1" events "^1.0.0" - http-browserify "^1.3.2" - https-browserify "0.0.0" - os-browserify "~0.1.2" + https-browserify "0.0.1" + os-browserify "^0.2.0" path-browserify "0.0.0" process "^0.11.0" punycode "^1.2.4" - querystring-es3 "~0.2.0" - readable-stream "^1.1.13" - stream-browserify "^1.0.0" - string_decoder "~0.10.25" - timers-browserify "^1.0.1" + querystring-es3 "^0.2.0" + readable-stream "^2.0.5" + stream-browserify "^2.0.1" + stream-http "^2.3.1" + string_decoder "^0.10.25" + timers-browserify "^2.0.2" tty-browserify "0.0.0" - url "~0.10.1" - util "~0.10.3" + url "^0.11.0" + util "^0.10.3" vm-browserify "0.0.4" node-pre-gyp@^0.6.29: @@ -3663,7 +3655,11 @@ optionator@^0.8.1: type-check "~0.3.2" wordwrap "~1.0.0" -os-browserify@~0.1.1, os-browserify@~0.1.2: +os-browserify@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" + +os-browserify@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.1.2.tgz#49ca0293e0b19590a5f5de10c7f265a617d8fe54" @@ -4133,7 +4129,7 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -querystring-es3@~0.2.0: +querystring-es3@^0.2.0, querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -4233,6 +4229,10 @@ react-fuzzy@^0.3.3: classnames "^2.2.3" fuse.js "^2.2.0" +react-imageloader@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-imageloader/-/react-imageloader-2.1.0.tgz#a58401970b3282386aeb810c43175165634f6308" + react-immutable-proptypes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4" @@ -4301,9 +4301,9 @@ react-redux@^4.4.5: lodash "^4.2.0" loose-envify "^1.1.0" -react-redux@^5.0.0-beta.3: - version "5.0.0-beta.3" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.0-beta.3.tgz#d50bfb00799cf7d2a9fd55fe34d6b3ecc24d3072" +react-redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.1.tgz#84a41bd4cdd180452bb6922bc79ad25bd5abb7c4" dependencies: hoist-non-react-statics "^1.0.3" invariant "^2.0.0" @@ -4388,7 +4388,7 @@ read-pkg@^1.0.0: normalize-package-data "^2.3.2" path-type "^1.0.0" -readable-stream@1.1, readable-stream@^1.0.27-1, readable-stream@^1.1.13: +readable-stream@1.1: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" dependencies: @@ -4397,7 +4397,7 @@ readable-stream@1.1, readable-stream@^1.0.27-1, readable-stream@^1.1.13: isarray "0.0.1" string_decoder "~0.10.x" -"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.0, readable-stream@~2.1.4: +"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.0, readable-stream@~2.1.4: version "2.1.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" dependencies: @@ -4470,6 +4470,12 @@ redux-immutable@^3.0.8: dependencies: immutable "^3.7.6" +redux-sounds@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/redux-sounds/-/redux-sounds-1.1.1.tgz#7a31052dbc617d419c53056215865762f44adb7e" + dependencies: + howler "^1.1.28" + redux-thunk@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.1.0.tgz#c724bfee75dbe352da2e3ba9bc14302badd89a98" @@ -4689,6 +4695,10 @@ set-immediate-shim@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" +setimmediate@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + setprototypeof@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.1.tgz#52009b27888c4dc48f591949c0a8275834c1ca7e" @@ -4780,10 +4790,14 @@ sortobject@^1.0.0: dependencies: editions "^1.1.1" -source-list-map@^0.1.4, source-list-map@~0.1.0: +source-list-map@^0.1.4: version "0.1.6" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.6.tgz#e1e6f94f0b40c4d28dcf8f5b8766e0e45636877f" +source-list-map@~0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.7.tgz#d4b5ce2a46535c72c7e8527c71a77d250618172e" + source-map-support@^0.4.2: version "0.4.3" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.3.tgz#693c8383d4389a4569486987c219744dfc601685" @@ -4854,14 +4868,7 @@ stackframe@^0.3.1: version "1.3.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.0.tgz#8e55758cb20e7682c1f4fce8dcab30bf01d1e07a" -stream-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-1.0.0.tgz#bf9b4abfb42b274d751479e44e0ff2656b6f1193" - dependencies: - inherits "~2.0.1" - readable-stream "^1.0.27-1" - -stream-browserify@^2.0.0: +stream-browserify@^2.0.0, stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" dependencies: @@ -4879,7 +4886,7 @@ stream-consume@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f" -stream-http@^2.0.0: +stream-http@^2.0.0, stream-http@^2.3.1: version "2.4.0" resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.4.0.tgz#9599aa8e263667ce4190e0dc04a1d065d3595a7e" dependencies: @@ -4924,7 +4931,7 @@ string.prototype.padstart@^3.0.0: es-abstract "^1.4.3" function-bind "^1.0.2" -string_decoder@~0.10.0, string_decoder@~0.10.25, string_decoder@~0.10.x: +string_decoder@^0.10.25, string_decoder@~0.10.0, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -5051,6 +5058,12 @@ timers-browserify@^1.0.1: dependencies: process "~0.11.0" +timers-browserify@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.2.tgz#ab4883cf597dcd50af211349a00fbca56ac86b86" + dependencies: + setimmediate "^1.0.4" + to-arraybuffer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" @@ -5116,9 +5129,9 @@ ua-parser-js@^0.7.9: version "0.7.10" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.10.tgz#917559ddcce07cbc09ece7d80495e4c268f4ef9f" -uglify-js@~2.6.0: - version "2.6.4" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.4.tgz#65ea2fb3059c9394692f15fed87c2b36c16b9adf" +uglify-js@~2.7.3: + version "2.7.5" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.5.tgz#4612c0c7baaee2ba7c487de4904ae122079f2ca8" dependencies: async "~0.2.6" source-map "~0.5.1" @@ -5162,14 +5175,7 @@ url-loader@^0.5.7: loader-utils "0.2.x" mime "1.2.x" -url@~0.10.1: - version "0.10.3" - resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -url@~0.11.0: +url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" dependencies: @@ -5184,7 +5190,7 @@ util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" -util@0.10.3, "util@>=0.10.3 <1", util@~0.10.1, util@~0.10.3: +util@0.10.3, "util@>=0.10.3 <1", util@^0.10.3, util@~0.10.1: version "0.10.3" resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" dependencies: @@ -5253,11 +5259,11 @@ webidl-conversions@^3.0.0, webidl-conversions@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" -webpack-core@~0.6.0: - version "0.6.8" - resolved "https://registry.yarnpkg.com/webpack-core/-/webpack-core-0.6.8.tgz#edf9135de00a6a3c26dd0f14b208af0aa4af8d0a" +webpack-core@~0.6.9: + version "0.6.9" + resolved "https://registry.yarnpkg.com/webpack-core/-/webpack-core-0.6.9.tgz#fc571588c8558da77be9efb6debdc5a3b172bdc2" dependencies: - source-list-map "~0.1.0" + source-list-map "~0.1.7" source-map "~0.4.1" webpack-dev-middleware@^1.6.0: @@ -5278,9 +5284,9 @@ webpack-hot-middleware@^2.10.0: querystring "^0.2.0" strip-ansi "^3.0.0" -webpack@^1.13.1: - version "1.13.2" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-1.13.2.tgz#f11a96f458eb752970a86abe746c0704fabafaf3" +webpack@^1.13.1, webpack@^1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-1.14.0.tgz#54f1ffb92051a328a5b2057d6ae33c289462c823" dependencies: acorn "^3.0.0" async "^1.3.0" @@ -5290,13 +5296,13 @@ webpack@^1.13.1: loader-utils "^0.2.11" memory-fs "~0.3.0" mkdirp "~0.5.0" - node-libs-browser "^0.6.0" + node-libs-browser "^0.7.0" optimist "~0.6.0" supports-color "^3.1.0" tapable "~0.1.8" - uglify-js "~2.6.0" + uglify-js "~2.7.3" watchpack "^0.2.1" - webpack-core "~0.6.0" + webpack-core "~0.6.9" whatwg-fetch@>=0.10.0: version "1.0.0"