diff options
276 files changed, 4847 insertions, 1342 deletions
diff --git a/.env.vagrant b/.env.vagrant new file mode 100644 index 000000000..0ab0552c9 --- /dev/null +++ b/.env.vagrant @@ -0,0 +1 @@ +VAGRANT=true \ No newline at end of file diff --git a/.gitignore b/.gitignore index a60603c7d..7f51045aa 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 28c735913..ab28c0fe1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -87,3 +87,4 @@ AllCops: - 'bin/*' - 'Rakefile' - 'node_modules/**/*' + - 'Vagrantfile' diff --git a/Gemfile b/Gemfile index 6bf95ec5e..7fb3ab91d 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 2467b76cc..12f6679c7 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 000000000..6cdd89518 --- /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 2d84062a7..7d3f5a975 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ Mastodon ======== -[![Build Status](http://img.shields.io/travis/Gargron/goldfinger.svg)][travis] -[![Code Climate](https://img.shields.io/codeclimate/github/Gargron/mastodon.svg)][code_climate] +[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis] +[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][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) + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](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 000000000..154d0e895 --- /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 000000000..c0579d33e --- /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 4390fca66..b0a88ff35 100644 --- a/app/assets/images/background-photo.jpeg +++ b/app/assets/images/background-photo.jpeg Binary files differdiff --git a/app/assets/images/boost_sprite.png b/app/assets/images/boost_sprite.png new file mode 100644 index 000000000..564bf2646 --- /dev/null +++ b/app/assets/images/boost_sprite.png Binary files differdiff --git a/app/assets/javascripts/application_public.js b/app/assets/javascripts/application_public.js index f131a267a..9626c5dae 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 8d28b051f..0be05034e 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 000000000..503c2bfeb --- /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 05674ba89..6d0188166 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 000000000..a25c1ae1c --- /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 d0adbce3f..000000000 --- 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 8bd835406..1731c1857 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 000000000..c754b30ca --- /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 cbee94bca..9ac215727 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 000000000..3bba99549 --- /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 0e6f09190..29a060e87 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 814d8a9c8..108401b2f 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 39ccbcaf9..81ec7a236 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 687aa7bb9..b8420014b 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 d63129013..19c52550a 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 000000000..203dc5e0c --- /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 450550d55..ffef29c00 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 e9a7228e4..f9b6192c0 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 b5c2a69d8..1e3a88955 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 fd5acae84..c8a263924 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 9aafd8181..7e92abe2d 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 000000000..ed8b4fe24 --- /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 3a5b88523..3b012b184 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 afaf82561..f2cc1fb12 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 f2c88cee0..521b557f0 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 e0a73435f..69cc013f2 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 8f64ad3cd..3edc8f672 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 1f49f9819..889c0ac4c 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 670455376..5f4b2cf79 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 6a882eab4..ad2be03d1 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 a06c75953..c93c07c74 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 45de75d97..ab7b08dc7 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 6ae5ac002..dead11265 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 c2cc58bb2..3a9b48f21 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 7a3dbe160..5c09839f7 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 55f361b0b..48363a968 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 d31d0e453..d0e865d29 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 df94c30d2..289e2dddf 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 b4e618820..e4672216b 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 5250ff748..4c8181aa1 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 ac548033c..94c94b4b7 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 2b6ee1ae7..8ccfce059 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 51e2513d8..0006608da 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 4154b0737..78e5312f5 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 4017c8949..f6095c0c6 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 000000000..a2d521736 --- /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 000000000..a7afe29b0 --- /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 157bdf8f2..42e0a9e24 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 000000000..714be309b --- /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 000000000..79697e869 --- /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 000000000..3b3ce19bc --- /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 e4f4fa7c7..5d2263f15 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 b4035c20d..b63c1881a 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 9f4cf9e4d..140ba9134 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 000000000..c2438f716 --- /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 6907fd351..bc24c75e0 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 7e706ad6a..b4593aaff 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 030428440..3f8a0457d 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 000000000..ccb06dfd5 --- /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 b967d966f..f2d6ae48a 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 000000000..5c8bfeec2 --- /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 0a1528fe9..389549849 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 a2f7c13a6..901a29f5c 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 219979522..2f8a28fad 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 cd7d63a4a..53d162462 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 1621cec7b..8af7b0c3c 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 76e3dd940..003d061ad 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 000000000..eaa6221e4 --- /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 17b74e15d..7d32824f1 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 3d4a38919..92dcbaeb9 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 6bd9b18ed..b75fb57d9 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 968c3f8c3..183e5d5b5 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 606fc830f..9a2d14d87 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 57cbcd31b..d68724b13 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 53535c25a..84a348c21 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 3a1473bc1..74d77f0f9 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 000000000..a98f1bb2b --- /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 7f2f89d0a..409dfd663 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 000000000..3c9395011 --- /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 16215684e..d3a84842f 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 aea9239f8..0798116c4 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 c7222c60b..cd4b313d5 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 b529b6aa8..ac53ea210 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 e0d1ccf83..482093c33 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 9c2041863..d835ef268 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 000000000..8acc3faca --- /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 000000000..b883b1c58 --- /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 c740b6d64..084b6304c 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 b73c83e0f..6f2d26dcb 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 36093663f..72922f509 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 3d03d4c19..ad0427b52 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 b9f8e6842..5738863dd 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 3681672d8..b7d903ddf 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 748bb8224..7c48c91f3 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 6e4234d13..8d01ac4c4 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 e4c550b81..649a0148b 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 000000000..a2e6421f8 --- /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 acfa85c6b..6014da5b6 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 e6d2e85a2..365396511 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 7624bbdc8..2d3cb1436 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 a37870786..ad8050580 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 000000000..de4157af8 --- /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 7df58444f..491036db2 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 000000000..af0be8823 --- /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 2360061ff..379e910e6 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 05ff806c5..d97010c0e 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 1b33770f4..ca9dd0b7e 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 8629242ab..b9816e052 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 a71592acd..ef0a4854a 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 c8f162cb0..877356a75 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 f7b4ed610..4b095a570 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 9727797e5..5042550db 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 000000000..c00e016a4 --- /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 8f1c8ac8a..5d2bd9a22 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 0a6b50a29..e4b6d0faf 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 60eb9905a..6ce4984bb 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 a25fe77da..814b1f758 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 6f1f7ec48..488c4f944 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 3b6d109a6..f273b5f21 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 3f60bb0c4..5701b2efa 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 036a72166..c08d80ea0 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 6f87c7b72..d3c6b13a6 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 fa569e73a..aed8770c8 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 ae2f575b5..15601a079 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 000000000..93c0f42f0 --- /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 0056321fa..cdd26e69c 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 04386d295..ff2a16f1b 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 000000000..274c020ad --- /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 000000000..407c3480f --- /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 000000000..82b70d128 --- /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 000000000..55135a598 --- /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 000000000..4a5c4ef3f --- /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 5c1f6e7c1..c2a41c4c6 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 9075b90a0..b4606da60 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 8eef3abf4..936ad0691 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 2a5d23739..ecbed03e3 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 c0b5c45a8..b7e8c9e71 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 000000000..e59b05eb8 --- /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 000000000..3796253d4 --- /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 bc595c93b..651d0dbc9 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 d5a52da06..71d3ee0b8 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 000000000..58654fd77 --- /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 000000000..3d601189b --- /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 000000000..8c6197f2c --- /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 a8fafe412..9518b1fcf 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 b08cf8ca8..e04b6cc39 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 000000000..005e5acea --- /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 f640222b0..b39eafc70 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 555f01b6d..87c16a621 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 2fb1d3919..1ec36637c 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 55405c0db..979941c84 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 3860a3504..626534176 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 ddcc64aa5..617a38159 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 11ec0d2dd..5f91e3127 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 ee42a5df2..72568e702 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 0cb51eecd..4ea0dbf6c 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 836b8fdc5..7aca24d12 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 04a086613..8528ef62a 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 7973a3611..f469793c1 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 d961eda39..ad9c56540 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 6dd182205..88bfe3d61 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 000000000..2de3bf986 --- /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 dfdb23161..d5418fca5 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 1c6b5f0f6..f575e855e 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 7afeb68a9..c194ce33d 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 aedf163f7..dbaeb4716 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 000000000..5b482213b --- /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 000000000..6d9e607db --- /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 a3391a67e..7309a78b8 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 000000000..8ba8dcbb1 --- /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 a9b02c746..eef0bec07 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 000000000..ba1d5f72d --- /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 000000000..07cf3742f --- /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 000000000..e369cded6 --- /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 498fae105..0147f4064 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 000000000..0e9736f5f --- /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 000000000..54563f7d8 --- /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 e6de7d017..808fb0a0e 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 a0860c94b..a9a1d21ac 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 44f097950..a6e90f457 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 94451d3bd..6ee8c9e5b 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 aac90dcdf..ea4879328 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 1a2e2c554..da6d062f0 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 da3bc0ccb..95f90abd9 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 43935da60..6bad45705 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 dd42fe22c..412ec4fa5 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 000000000..0820490d3 --- /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 000000000..af3394b8b --- /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 000000000..0f288f43f --- /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 386e94111..e4c38d384 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 489bd8359..868fd9f97 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 35bf7b2f0..15005bc80 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 84eae73be..593edd032 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 a14c82d6f..cce07e486 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 000000000..dbf7243de --- /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 79ace8521..d0b06bf95 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 978f721af..34759a772 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 52c26f599..5ec342f93 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 829edcf04..3f44d861e 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 9254d494c..d2dfa4274 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 ac033bf9d..b5e43e705 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 cb7ed4487..71a7b514e 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 3825710b8..3660c4a9b 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 63fdb3f16..ecdd07b08 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 000000000..c9c754e7f --- /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 000000000..3c2afd8cd --- /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 ead3ae514..f36cc64c8 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 e166fc717..831fdbc7a 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 d0aed9d0e..614cd4911 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 1b6615ed0..9aaa12b0b 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 ad2dbfffd..550129bdc 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 18c239c48..15fb924f1 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 000000000..a78bd067d --- /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 000000000..2685ae150 --- /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 000000000..2aeae1f91 --- /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 000000000..f6f6ed531 --- /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 000000000..b699db2ac --- /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 000000000..ee674be72 --- /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 000000000..70ed91bbd --- /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 000000000..dcbc32a1a --- /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 000000000..999fccda0 --- /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 000000000..2c43210ba --- /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 b9236d42f..72ce63133 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 000000000..5916ceb45 --- /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 000000000..d47e83e7e --- /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 000000000..a3d64ebf1 --- /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 000000000..000593761 --- /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 000000000..1b9dc8630 --- /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 000000000..80e6e2f94 --- /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 000000000..6aa8be774 --- /dev/null +++ b/docs/Running-Mastodon/Heroku-guide.md @@ -0,0 +1,13 @@ +Heroku guide +============ + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](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 000000000..76964d995 --- /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 000000000..a94478392 --- /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 000000000..9bb1bb622 --- /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 000000000..e0a2730c1 --- /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 000000000..daedcbdd8 --- /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 000000000..2f15df083 --- /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 000000000..9f5280870 --- /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 000000000..d0b5abd40 --- /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 000000000..977773a08 --- /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 000000000..561f1e273 --- /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 000000000..e48ce6541 --- /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 a95a7258f..13220f68e 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 05663a729..dbcc032c6 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 eecfd6743..000000000 --- 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 915b890f1..d085d490b 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 000000000..fdc34289d --- /dev/null +++ b/public/headers/original/missing.png Binary files differdiff --git a/public/sounds/boop.mp3 b/public/sounds/boop.mp3 new file mode 100644 index 000000000..02a035d91 --- /dev/null +++ b/public/sounds/boop.mp3 Binary files differdiff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb index 758bfd1da..511cdb463 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 d9c73f952..669956659 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 f7ebebbcb..27ad6cbde 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 000000000..42b7009dc --- /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 b1a0cd991..59db2440d 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 000000000..448a94e7e --- /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 000000000..e5136829b9 --- /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 ce28cd77b..9ece3bc2e 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 e7126127e..d982b9dca 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 7b8259fa6..6ec28f5d8 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 d4baca5aa..3beaebeb1 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 a72369b1c..287f389ac 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 000000000..14ef23923 --- /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 d40bf0b44..9cb3d41ce 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 000000000..90e7695aa --- /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 9933d016f..d88b3b55c 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 b0145f9bd..31f11b5ad 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 f71a8ae10..ee3e57783 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" |