commit
						3d890c4073
					
				
							
								
								
									
										1
									
								
								.env.vagrant
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.env.vagrant
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| VAGRANT=true | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -22,3 +22,6 @@ public/assets | ||||
| .env.production | ||||
| node_modules/ | ||||
| neo4j/ | ||||
| 
 | ||||
| # Ignore Vagrant files | ||||
| .vagrant/ | ||||
|  | ||||
| @ -87,3 +87,4 @@ AllCops: | ||||
|   - 'bin/*' | ||||
|   - 'Rakefile' | ||||
|   - 'node_modules/**/*' | ||||
|   - 'Vagrantfile' | ||||
|  | ||||
							
								
								
									
										10
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								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 | ||||
|  | ||||
							
								
								
									
										34
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								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 | ||||
|  | ||||
							
								
								
									
										2
									
								
								Procfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								Procfile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| web: bundle exec puma -C config/puma.rb | ||||
| worker: bundle exec sidekiq -q default -q mailers -q push | ||||
							
								
								
									
										30
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								README.md
									
									
									
									
									
								
							| @ -1,11 +1,11 @@ | ||||
| Mastodon | ||||
| ======== | ||||
| 
 | ||||
| [][travis] | ||||
| [][code_climate] | ||||
| [][travis] | ||||
| [][code_climate] | ||||
| 
 | ||||
| [travis]: https://travis-ci.org/Gargron/mastodon | ||||
| [code_climate]: https://codeclimate.com/github/Gargron/mastodon | ||||
| [travis]: https://travis-ci.org/tootsuite/mastodon | ||||
| [code_climate]: https://codeclimate.com/github/tootsuite/mastodon | ||||
| 
 | ||||
| Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. | ||||
| 
 | ||||
| @ -25,11 +25,11 @@ If you would like, you can [support the development of this project on Patreon][ | ||||
| 
 | ||||
| ## Resources | ||||
| 
 | ||||
| - [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances) | ||||
| - [List of Mastodon instances](docs/Using-Mastodon/List-of-Mastodon-instances.md) | ||||
| - [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com) | ||||
| - [API overview](https://github.com/Gargron/mastodon/wiki/API) | ||||
| - [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL) | ||||
| - [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ) | ||||
| - [API overview](docs/Using-the-API/API.md) | ||||
| - [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md) | ||||
| - [List of apps](docs/Using-Mastodon/Apps.md) | ||||
| 
 | ||||
| ## Features | ||||
| 
 | ||||
| @ -115,7 +115,19 @@ Which will re-create the updated containers, leaving databases and data as is. D | ||||
| 
 | ||||
| ## Deployment without Docker | ||||
| 
 | ||||
| Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/Gargron/mastodon/wiki/Production-guide) for examples, configuration and instructions. | ||||
| Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions. | ||||
| 
 | ||||
| ## Deployment on Heroku (experimental) | ||||
| 
 | ||||
| [](https://heroku.com/deploy) | ||||
| 
 | ||||
| Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku.md) | ||||
| 
 | ||||
| ## Development with Vagrant | ||||
| 
 | ||||
| A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed. | ||||
| 
 | ||||
| [You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant.md) | ||||
| 
 | ||||
| ## Contributing | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										109
									
								
								Vagrantfile
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								Vagrantfile
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -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 | ||||
							
								
								
									
										91
									
								
								app.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								app.json
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||
|   ] | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 874 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/assets/images/boost_sprite.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/assets/images/boost_sprite.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.3 KiB | 
| @ -1,3 +1,8 @@ | ||||
| //= require jquery
 | ||||
| //= require jquery_ujs
 | ||||
| //= require extras
 | ||||
| //= require best_in_place
 | ||||
| 
 | ||||
| $(function () { | ||||
|   $(".best_in_place").best_in_place(); | ||||
| }); | ||||
|  | ||||
| @ -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 | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										47
									
								
								app/assets/javascripts/components/actions/cards.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/assets/javascripts/components/actions/cards.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
|   }; | ||||
| }; | ||||
| @ -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, | ||||
|  | ||||
							
								
								
									
										83
									
								
								app/assets/javascripts/components/actions/favourites.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								app/assets/javascripts/components/actions/favourites.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
|   }; | ||||
| }; | ||||
| @ -1,8 +0,0 @@ | ||||
| export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET'; | ||||
| 
 | ||||
| export function setAccessToken(token) { | ||||
|   return { | ||||
|     type: ACCESS_TOKEN_SET, | ||||
|     token: token | ||||
|   }; | ||||
| }; | ||||
| @ -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 | ||||
|   }; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										19
									
								
								app/assets/javascripts/components/actions/settings.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/assets/javascripts/components/actions/settings.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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() | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
| @ -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 | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										17
									
								
								app/assets/javascripts/components/actions/store.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/assets/javascripts/components/actions/store.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
|   }; | ||||
| }; | ||||
| @ -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)); | ||||
|  | ||||
| @ -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']); | ||||
|       const requested = account.getIn(['relationship', 'requested']); | ||||
|       const blocking  = account.getIn(['relationship', 'blocking']); | ||||
| 
 | ||||
|       buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; | ||||
|       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 ( | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -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', | ||||
|  | ||||
| @ -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; | ||||
| @ -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') { | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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; | ||||
| @ -1,15 +1,18 @@ | ||||
| import { | ||||
|   FormattedMessage, | ||||
|   FormattedDate, | ||||
|   FormattedRelative | ||||
| } from 'react-intl'; | ||||
| import { injectIntl, FormattedRelative } from 'react-intl'; | ||||
| 
 | ||||
| const RelativeTimestamp = ({ timestamp }) => { | ||||
|   return <FormattedRelative value={new Date(timestamp)} />; | ||||
| 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); | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|  | ||||
| @ -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')) }; | ||||
|     const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) }; | ||||
| 
 | ||||
|     return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />; | ||||
|     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} | ||||
|         /> | ||||
|       ); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
|  | ||||
| @ -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} />; | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|  | ||||
| @ -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'))); | ||||
|     } | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -5,5 +5,5 @@ emojione.sprites      = false; | ||||
| emojione.imagePathPNG = '/emoji/'; | ||||
| 
 | ||||
| export default function emojify(text) { | ||||
|   return emojione.unicodeToImage(text); | ||||
|   return emojione.toImage(text); | ||||
| }; | ||||
|  | ||||
| @ -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}> | ||||
|  | ||||
| @ -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} | ||||
|  | ||||
| @ -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 () { | ||||
|  | ||||
| @ -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} /> | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
|  | ||||
| @ -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} /> | ||||
|  | ||||
| @ -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 Drawer = React.createClass({ | ||||
| const tabStyle = { | ||||
|   display: 'block', | ||||
|   flex: '1 1 auto', | ||||
|   padding: '15px', | ||||
|   paddingBottom: '13px', | ||||
|   color: '#9baec8', | ||||
|   textDecoration: 'none', | ||||
|   textAlign: 'center', | ||||
|   fontSize: '16px', | ||||
|   borderBottom: '2px solid transparent' | ||||
| }; | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| const tabActiveStyle = { | ||||
|   color: '#2b90d9', | ||||
|   borderBottom: '2px solid #2b90d9' | ||||
| }; | ||||
| 
 | ||||
|   render () { | ||||
|     return ( | ||||
|       <div className='drawer' style={style}> | ||||
|         {this.props.children} | ||||
| const Drawer = ({ children, withHeader, intl }) => { | ||||
|   let header = ''; | ||||
| 
 | ||||
|   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} | ||||
| 
 | ||||
| export default Drawer; | ||||
|       <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 injectIntl(Drawer); | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|  | ||||
| @ -38,7 +38,7 @@ const inputStyle = { | ||||
|   border: 'none', | ||||
|   padding: '10px', | ||||
|   paddingRight: '30px', | ||||
|   fontFamily: 'Roboto', | ||||
|   fontFamily: 'inherit', | ||||
|   background: '#282c37', | ||||
|   color: '#9baec8', | ||||
|   fontSize: '14px', | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|  | ||||
| @ -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)); | ||||
|     }, | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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 => ({ | ||||
|  | ||||
| @ -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 /> | ||||
|  | ||||
| @ -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)); | ||||
| @ -0,0 +1,10 @@ | ||||
| import Column from '../ui/components/column'; | ||||
| import MissingIndicator from '../../components/missing_indicator'; | ||||
| 
 | ||||
| const GenericNotFound = () => ( | ||||
|   <Column> | ||||
|     <MissingIndicator /> | ||||
|   </Column> | ||||
| ); | ||||
| 
 | ||||
| export default GenericNotFound; | ||||
| @ -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> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| @ -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); | ||||
| @ -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; | ||||
| @ -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); | ||||
| @ -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); | ||||
|  | ||||
| @ -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> | ||||
|       <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}> | ||||
|         <div style={outerStyle}> | ||||
|           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> | ||||
| 
 | ||||
|         <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}> | ||||
|             <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> | ||||
| 
 | ||||
|                 <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> | ||||
|           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> | ||||
| 
 | ||||
|                   <label style={labelStyle}> | ||||
|                     <Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} /> | ||||
|                     <span style={labelSpanStyle}>{showStr}</span> | ||||
|                   </label> | ||||
|                 </div> | ||||
|           <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.favourite' defaultMessage='Favourites:' /></span> | ||||
|           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></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> | ||||
|           <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> | ||||
| 
 | ||||
|                   <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.reblog' defaultMessage='Boosts:' /></span> | ||||
| 
 | ||||
|                 <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> | ||||
|           <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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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': | ||||
|  | ||||
| @ -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; | ||||
| @ -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()); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -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; | ||||
| @ -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> | ||||
|     ); | ||||
|  | ||||
| @ -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); | ||||
| @ -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) { | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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} | ||||
|  | ||||
							
								
								
									
										5
									
								
								app/assets/javascripts/components/is_mobile.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/assets/javascripts/components/is_mobile.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| const LAYOUT_BREAKPOINT = 1024; | ||||
| 
 | ||||
| export function isMobile(width) { | ||||
|   return width <= LAYOUT_BREAKPOINT; | ||||
| }; | ||||
| @ -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; | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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": "Відмінити", | ||||
|  | ||||
| @ -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.')); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
							
								
								
									
										25
									
								
								app/assets/javascripts/components/middleware/loading_bar.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/assets/javascripts/components/middleware/loading_bar.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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); | ||||
|   }; | ||||
| }; | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
							
								
								
									
										14
									
								
								app/assets/javascripts/components/reducers/cards.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/assets/javascripts/components/reducers/cards.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
|   } | ||||
| }; | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -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 | ||||
| }); | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -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: [ | ||||
|  | ||||
							
								
								
									
										46
									
								
								app/assets/javascripts/components/reducers/settings.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/assets/javascripts/components/reducers/settings.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										39
									
								
								app/assets/javascripts/components/reducers/status_lists.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/assets/javascripts/components/reducers/status_lists.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
|   } | ||||
| }; | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user