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
|
.env.production
|
||||||
node_modules/
|
node_modules/
|
||||||
neo4j/
|
neo4j/
|
||||||
|
|
||||||
|
# Ignore Vagrant files
|
||||||
|
.vagrant/
|
||||||
|
@ -87,3 +87,4 @@ AllCops:
|
|||||||
- 'bin/*'
|
- 'bin/*'
|
||||||
- 'Rakefile'
|
- 'Rakefile'
|
||||||
- 'node_modules/**/*'
|
- 'node_modules/**/*'
|
||||||
|
- 'Vagrantfile'
|
||||||
|
10
Gemfile
10
Gemfile
@ -1,6 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
|
ruby '2.3.1'
|
||||||
|
|
||||||
gem 'rails', '~> 5.0.1.0'
|
gem 'rails', '~> 5.0.1.0'
|
||||||
gem 'sass-rails', '~> 5.0'
|
gem 'sass-rails', '~> 5.0'
|
||||||
@ -16,8 +17,9 @@ gem 'pg'
|
|||||||
gem 'pghero'
|
gem 'pghero'
|
||||||
gem 'dotenv-rails'
|
gem 'dotenv-rails'
|
||||||
gem 'font-awesome-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 'paperclip-av-transcoder'
|
||||||
gem 'aws-sdk', '>= 2.0'
|
gem 'aws-sdk', '>= 2.0'
|
||||||
|
|
||||||
@ -29,7 +31,6 @@ gem 'link_header'
|
|||||||
gem 'ostatus2'
|
gem 'ostatus2'
|
||||||
gem 'goldfinger'
|
gem 'goldfinger'
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
gem 'rails_autolink'
|
|
||||||
gem 'doorkeeper'
|
gem 'doorkeeper'
|
||||||
gem 'rabl'
|
gem 'rabl'
|
||||||
gem 'oj'
|
gem 'oj'
|
||||||
@ -42,9 +43,11 @@ gem 'will_paginate'
|
|||||||
gem 'rack-attack'
|
gem 'rack-attack'
|
||||||
gem 'rack-cors', require: 'rack/cors'
|
gem 'rack-cors', require: 'rack/cors'
|
||||||
gem 'sidekiq'
|
gem 'sidekiq'
|
||||||
gem 'ledermann-rails-settings'
|
gem 'rails-settings-cached'
|
||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
gem 'simple-navigation'
|
gem 'simple-navigation'
|
||||||
|
gem 'statsd-instrument'
|
||||||
|
gem 'ruby-oembed', require: 'oembed'
|
||||||
|
|
||||||
gem 'react-rails'
|
gem 'react-rails'
|
||||||
gem 'browserify-rails'
|
gem 'browserify-rails'
|
||||||
@ -69,6 +72,7 @@ group :development do
|
|||||||
gem 'better_errors'
|
gem 'better_errors'
|
||||||
gem 'binding_of_caller'
|
gem 'binding_of_caller'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
|
gem 'letter_opener_web'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'active_record_query_trace'
|
gem 'active_record_query_trace'
|
||||||
end
|
end
|
||||||
|
34
Gemfile.lock
34
Gemfile.lock
@ -60,6 +60,9 @@ GEM
|
|||||||
babel-source (>= 4.0, < 6)
|
babel-source (>= 4.0, < 6)
|
||||||
execjs (~> 2.0)
|
execjs (~> 2.0)
|
||||||
bcrypt (3.1.11)
|
bcrypt (3.1.11)
|
||||||
|
best_in_place (3.0.3)
|
||||||
|
actionpack (>= 3.2)
|
||||||
|
railties (>= 3.2)
|
||||||
better_errors (2.1.1)
|
better_errors (2.1.1)
|
||||||
coderay (>= 1.0.0)
|
coderay (>= 1.0.0)
|
||||||
erubis (>= 2.6.6)
|
erubis (>= 2.6.6)
|
||||||
@ -73,8 +76,7 @@ GEM
|
|||||||
bullet (5.3.0)
|
bullet (5.3.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.10.0)
|
uniform_notifier (~> 1.10.0)
|
||||||
climate_control (0.0.3)
|
climate_control (0.1.0)
|
||||||
activesupport (>= 3.0)
|
|
||||||
cocaine (0.5.8)
|
cocaine (0.5.8)
|
||||||
climate_control (>= 0.0.3, < 1.0)
|
climate_control (>= 0.0.3, < 1.0)
|
||||||
coderay (1.1.1)
|
coderay (1.1.1)
|
||||||
@ -86,7 +88,7 @@ GEM
|
|||||||
execjs
|
execjs
|
||||||
coffee-script-source (1.10.0)
|
coffee-script-source (1.10.0)
|
||||||
colorize (0.8.1)
|
colorize (0.8.1)
|
||||||
concurrent-ruby (1.0.3)
|
concurrent-ruby (1.0.4)
|
||||||
connection_pool (2.2.1)
|
connection_pool (2.2.1)
|
||||||
crack (0.4.3)
|
crack (0.4.3)
|
||||||
safe_yaml (~> 1.0.0)
|
safe_yaml (~> 1.0.0)
|
||||||
@ -172,10 +174,12 @@ GEM
|
|||||||
json (1.8.3)
|
json (1.8.3)
|
||||||
launchy (2.4.3)
|
launchy (2.4.3)
|
||||||
addressable (~> 2.3)
|
addressable (~> 2.3)
|
||||||
ledermann-rails-settings (2.4.2)
|
|
||||||
activerecord (>= 3.1)
|
|
||||||
letter_opener (1.4.1)
|
letter_opener (1.4.1)
|
||||||
launchy (~> 2.2)
|
launchy (~> 2.2)
|
||||||
|
letter_opener_web (1.3.0)
|
||||||
|
actionmailer (>= 3.2)
|
||||||
|
letter_opener (~> 1.0)
|
||||||
|
railties (>= 3.2)
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lograge (0.4.1)
|
lograge (0.4.1)
|
||||||
actionpack (>= 4, < 5.1)
|
actionpack (>= 4, < 5.1)
|
||||||
@ -259,11 +263,11 @@ GEM
|
|||||||
nokogiri (~> 1.6.0)
|
nokogiri (~> 1.6.0)
|
||||||
rails-html-sanitizer (1.0.3)
|
rails-html-sanitizer (1.0.3)
|
||||||
loofah (~> 2.0)
|
loofah (~> 2.0)
|
||||||
|
rails-settings-cached (0.6.5)
|
||||||
|
rails (>= 4.2.0)
|
||||||
rails_12factor (0.0.3)
|
rails_12factor (0.0.3)
|
||||||
rails_serve_static_assets
|
rails_serve_static_assets
|
||||||
rails_stdout_logging
|
rails_stdout_logging
|
||||||
rails_autolink (1.1.6)
|
|
||||||
rails (> 3.1)
|
|
||||||
rails_serve_static_assets (0.0.5)
|
rails_serve_static_assets (0.0.5)
|
||||||
rails_stdout_logging (0.0.5)
|
rails_stdout_logging (0.0.5)
|
||||||
railties (5.0.1)
|
railties (5.0.1)
|
||||||
@ -332,6 +336,7 @@ GEM
|
|||||||
rainbow (>= 1.99.1, < 3.0)
|
rainbow (>= 1.99.1, < 3.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (~> 1.0, >= 1.0.1)
|
unicode-display_width (~> 1.0, >= 1.0.1)
|
||||||
|
ruby-oembed (0.10.1)
|
||||||
ruby-progressbar (1.8.1)
|
ruby-progressbar (1.8.1)
|
||||||
safe_yaml (1.0.4)
|
safe_yaml (1.0.4)
|
||||||
sass (3.4.22)
|
sass (3.4.22)
|
||||||
@ -367,6 +372,7 @@ GEM
|
|||||||
actionpack (>= 4.0)
|
actionpack (>= 4.0)
|
||||||
activesupport (>= 4.0)
|
activesupport (>= 4.0)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
|
statsd-instrument (2.1.2)
|
||||||
temple (0.7.7)
|
temple (0.7.7)
|
||||||
term-ansicolor (1.4.0)
|
term-ansicolor (1.4.0)
|
||||||
tins (~> 1.0)
|
tins (~> 1.0)
|
||||||
@ -405,6 +411,7 @@ DEPENDENCIES
|
|||||||
addressable
|
addressable
|
||||||
autoprefixer-rails
|
autoprefixer-rails
|
||||||
aws-sdk (>= 2.0)
|
aws-sdk (>= 2.0)
|
||||||
|
best_in_place (~> 3.0.1)
|
||||||
better_errors
|
better_errors
|
||||||
binding_of_caller
|
binding_of_caller
|
||||||
browserify-rails
|
browserify-rails
|
||||||
@ -426,14 +433,14 @@ DEPENDENCIES
|
|||||||
i18n-tasks (~> 0.9.6)
|
i18n-tasks (~> 0.9.6)
|
||||||
jbuilder (~> 2.0)
|
jbuilder (~> 2.0)
|
||||||
jquery-rails
|
jquery-rails
|
||||||
ledermann-rails-settings
|
|
||||||
letter_opener
|
letter_opener
|
||||||
|
letter_opener_web
|
||||||
link_header
|
link_header
|
||||||
lograge
|
lograge
|
||||||
nokogiri
|
nokogiri
|
||||||
oj
|
oj
|
||||||
ostatus2
|
ostatus2
|
||||||
paperclip (~> 5.0)
|
paperclip (~> 5.1)
|
||||||
paperclip-av-transcoder
|
paperclip-av-transcoder
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
@ -445,23 +452,28 @@ DEPENDENCIES
|
|||||||
rack-cors
|
rack-cors
|
||||||
rack-timeout-puma
|
rack-timeout-puma
|
||||||
rails (~> 5.0.1.0)
|
rails (~> 5.0.1.0)
|
||||||
|
rails-settings-cached
|
||||||
rails_12factor
|
rails_12factor
|
||||||
rails_autolink
|
|
||||||
react-rails
|
react-rails
|
||||||
redis (~> 3.2)
|
redis (~> 3.2)
|
||||||
redis-rails
|
redis-rails
|
||||||
rspec-rails
|
rspec-rails
|
||||||
rspec-sidekiq
|
rspec-sidekiq
|
||||||
rubocop
|
rubocop
|
||||||
|
ruby-oembed
|
||||||
sass-rails (~> 5.0)
|
sass-rails (~> 5.0)
|
||||||
sdoc (~> 0.4.0)
|
sdoc (~> 0.4.0)
|
||||||
sidekiq
|
sidekiq
|
||||||
simple-navigation
|
simple-navigation
|
||||||
simple_form
|
simple_form
|
||||||
simplecov
|
simplecov
|
||||||
|
statsd-instrument
|
||||||
uglifier (>= 1.3.0)
|
uglifier (>= 1.3.0)
|
||||||
webmock
|
webmock
|
||||||
will_paginate
|
will_paginate
|
||||||
|
|
||||||
|
RUBY VERSION
|
||||||
|
ruby 2.3.1p112
|
||||||
|
|
||||||
BUNDLED WITH
|
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
|
Mastodon
|
||||||
========
|
========
|
||||||
|
|
||||||
[![Build Status](http://img.shields.io/travis/Gargron/goldfinger.svg)][travis]
|
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
|
||||||
[![Code Climate](https://img.shields.io/codeclimate/github/Gargron/mastodon.svg)][code_climate]
|
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
|
||||||
|
|
||||||
[travis]: https://travis-ci.org/Gargron/mastodon
|
[travis]: https://travis-ci.org/tootsuite/mastodon
|
||||||
[code_climate]: https://codeclimate.com/github/Gargron/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.
|
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
|
## 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)
|
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
|
||||||
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
|
- [API overview](docs/Using-the-API/API.md)
|
||||||
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
|
- [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md)
|
||||||
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
|
- [List of apps](docs/Using-Mastodon/Apps.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -115,7 +115,19 @@ Which will re-create the updated containers, leaving databases and data as is. D
|
|||||||
|
|
||||||
## Deployment without Docker
|
## Deployment without Docker
|
||||||
|
|
||||||
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/Gargron/mastodon/wiki/Production-guide) for examples, configuration and instructions.
|
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
|
||||||
|
|
||||||
|
## Deployment on Heroku (experimental)
|
||||||
|
|
||||||
|
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
|
||||||
|
|
||||||
|
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku.md)
|
||||||
|
|
||||||
|
## Development with Vagrant
|
||||||
|
|
||||||
|
A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
|
||||||
|
|
||||||
|
[You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant.md)
|
||||||
|
|
||||||
## Contributing
|
## 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
|
||||||
//= require jquery_ujs
|
//= require jquery_ujs
|
||||||
//= require extras
|
//= require extras
|
||||||
|
//= require best_in_place
|
||||||
|
|
||||||
|
$(function () {
|
||||||
|
$(".best_in_place").best_in_place();
|
||||||
|
});
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import api, { getLinks } from '../api'
|
import api, { getLinks } from '../api'
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
|
||||||
|
|
||||||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
|
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
|
||||||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
|
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
|
||||||
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
|
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_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
|
||||||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
|
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
|
||||||
|
|
||||||
export function setAccountSelf(account) {
|
|
||||||
return {
|
|
||||||
type: ACCOUNT_SET_SELF,
|
|
||||||
account
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function fetchAccount(id) {
|
export function fetchAccount(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchAccountRequest(id));
|
dispatch(fetchAccountRequest(id));
|
||||||
@ -89,32 +80,39 @@ export function fetchAccount(id) {
|
|||||||
|
|
||||||
export function fetchAccountTimeline(id, replace = false) {
|
export function fetchAccountTimeline(id, replace = false) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchAccountTimelineRequest(id));
|
const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
|
||||||
|
|
||||||
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
|
|
||||||
const newestId = ids.size > 0 ? ids.first() : null;
|
const newestId = ids.size > 0 ? ids.first() : null;
|
||||||
|
|
||||||
let params = '';
|
let params = '';
|
||||||
|
let skipLoading = false;
|
||||||
|
|
||||||
if (newestId !== null && !replace) {
|
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 => {
|
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 => {
|
}).catch(error => {
|
||||||
dispatch(fetchAccountTimelineFail(id, error));
|
dispatch(fetchAccountTimelineFail(id, error, skipLoading));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function expandAccountTimeline(id) {
|
export function expandAccountTimeline(id) {
|
||||||
return (dispatch, getState) => {
|
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));
|
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));
|
dispatch(expandAccountTimelineSuccess(id, response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandAccountTimelineFail(id, error));
|
dispatch(expandAccountTimelineFail(id, error));
|
||||||
@ -210,27 +208,30 @@ export function unfollowAccountFail(error) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAccountTimelineRequest(id) {
|
export function fetchAccountTimelineRequest(id, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
|
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
|
||||||
id
|
id,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAccountTimelineSuccess(id, statuses, replace) {
|
export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||||
id,
|
id,
|
||||||
statuses,
|
statuses,
|
||||||
replace
|
replace,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAccountTimelineFail(id, error) {
|
export function fetchAccountTimelineFail(id, error, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: ACCOUNT_TIMELINE_FETCH_FAIL,
|
type: ACCOUNT_TIMELINE_FETCH_FAIL,
|
||||||
id,
|
id,
|
||||||
error
|
error,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) {
|
|||||||
|
|
||||||
export function fetchRelationships(account_ids) {
|
export function fetchRelationships(account_ids) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
if (account_ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(fetchRelationshipsRequest(account_ids));
|
dispatch(fetchRelationshipsRequest(account_ids));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
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) {
|
export function fetchRelationshipsRequest(ids) {
|
||||||
return {
|
return {
|
||||||
type: RELATIONSHIPS_FETCH_REQUEST,
|
type: RELATIONSHIPS_FETCH_REQUEST,
|
||||||
ids
|
ids,
|
||||||
|
skipLoading: true
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchRelationshipsSuccess(relationships) {
|
export function fetchRelationshipsSuccess(relationships) {
|
||||||
return {
|
return {
|
||||||
type: RELATIONSHIPS_FETCH_SUCCESS,
|
type: RELATIONSHIPS_FETCH_SUCCESS,
|
||||||
relationships
|
relationships,
|
||||||
|
skipLoading: true
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchRelationshipsFail(error) {
|
export function fetchRelationshipsFail(error) {
|
||||||
return {
|
return {
|
||||||
type: RELATIONSHIPS_FETCH_FAIL,
|
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_UNMOUNT = 'COMPOSE_UNMOUNT';
|
||||||
|
|
||||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||||
|
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||||
|
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_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),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
|
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||||
|
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
|
||||||
visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
|
visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
dispatch(submitComposeSuccess({ ...response.data }));
|
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) {
|
export function changeComposeVisibility(checked) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_VISIBILITY_CHANGE,
|
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_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
|
||||||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
||||||
|
|
||||||
export const NOTIFICATIONS_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE';
|
|
||||||
|
|
||||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||||
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
|
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) {
|
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||||
|
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: NOTIFICATIONS_UPDATE,
|
type: NOTIFICATIONS_UPDATE,
|
||||||
notification,
|
notification,
|
||||||
account: notification.account,
|
account: notification.account,
|
||||||
status: notification.status
|
status: notification.status,
|
||||||
|
meta: playSound ? { sound: 'boop' } : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchRelatedRelationships(dispatch, [notification]);
|
fetchRelatedRelationships(dispatch, [notification]);
|
||||||
|
|
||||||
// Desktop notifications
|
// 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 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();
|
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) => {
|
return (dispatch, getState) => {
|
||||||
const url = getState().getIn(['notifications', 'next'], null);
|
const url = getState().getIn(['notifications', 'next'], null);
|
||||||
|
|
||||||
if (url === null) {
|
if (url === null || getState().getIn(['notifications', 'isLoading'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(expandNotificationsRequest());
|
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');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
|
|
||||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
|
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
|
||||||
@ -133,11 +139,3 @@ export function expandNotificationsFail(error) {
|
|||||||
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 api from '../api';
|
||||||
|
|
||||||
import { deleteFromTimelines } from './timelines';
|
import { deleteFromTimelines } from './timelines';
|
||||||
|
import { fetchStatusCard } from './cards';
|
||||||
|
|
||||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
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_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||||
|
|
||||||
export function fetchStatusRequest(id) {
|
export function fetchStatusRequest(id, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_REQUEST,
|
type: STATUS_FETCH_REQUEST,
|
||||||
id: id
|
id,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchStatus(id) {
|
export function fetchStatus(id) {
|
||||||
return (dispatch, getState) => {
|
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 => {
|
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
|
||||||
dispatch(fetchStatusSuccess(response.data));
|
dispatch(fetchStatusSuccess(response.data, skipLoading));
|
||||||
dispatch(fetchContext(id));
|
dispatch(fetchContext(id));
|
||||||
|
dispatch(fetchStatusCard(id));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchStatusFail(id, error));
|
dispatch(fetchStatusFail(id, error, skipLoading));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchStatusSuccess(status, context) {
|
export function fetchStatusSuccess(status, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_SUCCESS,
|
type: STATUS_FETCH_SUCCESS,
|
||||||
status: status,
|
status,
|
||||||
context: context
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchStatusFail(id, error) {
|
export function fetchStatusFail(id, error, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_FAIL,
|
type: STATUS_FETCH_FAIL,
|
||||||
id: id,
|
id,
|
||||||
error: error
|
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 const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||||
|
|
||||||
export function refreshTimelineSuccess(timeline, statuses) {
|
export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: TIMELINE_REFRESH_SUCCESS,
|
type: TIMELINE_REFRESH_SUCCESS,
|
||||||
timeline: timeline,
|
timeline,
|
||||||
statuses: statuses
|
statuses,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,55 +40,65 @@ export function deleteFromTimelines(id) {
|
|||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const accountId = getState().getIn(['statuses', id, 'account']);
|
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 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({
|
dispatch({
|
||||||
type: TIMELINE_DELETE,
|
type: TIMELINE_DELETE,
|
||||||
id,
|
id,
|
||||||
accountId,
|
accountId,
|
||||||
references
|
references,
|
||||||
|
reblogOf
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function refreshTimelineRequest(timeline, id) {
|
export function refreshTimelineRequest(timeline, id, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: TIMELINE_REFRESH_REQUEST,
|
type: TIMELINE_REFRESH_REQUEST,
|
||||||
timeline,
|
timeline,
|
||||||
id
|
id,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function refreshTimeline(timeline, id = null) {
|
export function refreshTimeline(timeline, id = null) {
|
||||||
return function (dispatch, getState) {
|
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 ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
|
||||||
const newestId = ids.size > 0 ? ids.first() : null;
|
const newestId = ids.size > 0 ? ids.first() : null;
|
||||||
|
|
||||||
let params = '';
|
let params = '';
|
||||||
let path = timeline;
|
let path = timeline;
|
||||||
|
let skipLoading = false;
|
||||||
|
|
||||||
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
|
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
|
||||||
params = `?since_id=${newestId}`;
|
params = `?since_id=${newestId}`;
|
||||||
|
skipLoading = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
path = `${path}/${id}`
|
path = `${path}/${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatch(refreshTimelineRequest(timeline, id, skipLoading));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
|
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) {
|
}).catch(function (error) {
|
||||||
dispatch(refreshTimelineFail(timeline, error));
|
dispatch(refreshTimelineFail(timeline, error, skipLoading));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function refreshTimelineFail(timeline, error) {
|
export function refreshTimelineFail(timeline, error, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: TIMELINE_REFRESH_FAIL,
|
type: TIMELINE_REFRESH_FAIL,
|
||||||
timeline,
|
timeline,
|
||||||
error
|
error,
|
||||||
|
skipLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) {
|
|||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
|
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));
|
dispatch(expandTimelineRequest(timeline));
|
||||||
|
|
||||||
let path = timeline;
|
let path = timeline;
|
||||||
@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) {
|
|||||||
path = `${path}/${id}`
|
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));
|
dispatch(expandTimelineSuccess(timeline, response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandTimelineFail(timeline, error));
|
dispatch(expandTimelineFail(timeline, error));
|
||||||
|
@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl';
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
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 = {
|
const outerStyle = {
|
||||||
@ -42,7 +44,9 @@ const Account = React.createClass({
|
|||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
me: React.PropTypes.number.isRequired,
|
me: React.PropTypes.number.isRequired,
|
||||||
onFollow: React.PropTypes.func.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 () {
|
getDefaultProps () {
|
||||||
@ -57,6 +61,10 @@ const Account = React.createClass({
|
|||||||
this.props.onFollow(this.props.account);
|
this.props.onFollow(this.props.account);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleBlock () {
|
||||||
|
this.props.onBlock(this.props.account);
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, me, withNote, intl } = this.props;
|
const { account, me, withNote, intl } = this.props;
|
||||||
|
|
||||||
@ -70,10 +78,18 @@ const Account = React.createClass({
|
|||||||
note = <div style={noteStyle}>{account.get('note')}</div>;
|
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 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 (
|
return (
|
||||||
|
@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({
|
|||||||
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
|
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
|
||||||
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
|
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
|
||||||
onChange: React.PropTypes.func.isRequired,
|
onChange: React.PropTypes.func.isRequired,
|
||||||
onKeyUp: React.PropTypes.func
|
onKeyUp: React.PropTypes.func,
|
||||||
|
onKeyDown: React.PropTypes.func
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.onKeyDown(e);
|
||||||
},
|
},
|
||||||
|
|
||||||
onBlur () {
|
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) {
|
onSuggestionClick (suggestion, e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||||
|
this.textarea.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillReceiveProps (nextProps) {
|
componentWillReceiveProps (nextProps) {
|
||||||
|
@ -8,12 +8,41 @@ const Avatar = React.createClass({
|
|||||||
style: React.PropTypes.object
|
style: React.PropTypes.object
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getInitialState () {
|
||||||
|
return {
|
||||||
|
hovering: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
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 () {
|
render () {
|
||||||
|
const { hovering } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
|
<div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
|
||||||
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,7 @@ const Button = React.createClass({
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const style = {
|
const style = {
|
||||||
fontFamily: 'Roboto',
|
fontFamily: 'inherit',
|
||||||
display: this.props.block ? 'block' : 'inline-block',
|
display: this.props.block ? 'block' : 'inline-block',
|
||||||
width: this.props.block ? '100%' : 'auto',
|
width: this.props.block ? '100%' : 'auto',
|
||||||
position: 'relative',
|
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';
|
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 (
|
return (
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
|
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
|
||||||
<i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
|
<i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
|
|
||||||
<DropdownContent style={{ lineHeight: '18px', textAlign: 'left' }}>
|
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
|
||||||
<ul>
|
<ul>
|
||||||
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
|
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
|
||||||
if (typeof action === 'function') {
|
if (typeof action === 'function') {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import { Motion, spring } from 'react-motion';
|
||||||
|
|
||||||
const IconButton = React.createClass({
|
const IconButton = React.createClass({
|
||||||
|
|
||||||
@ -10,14 +11,16 @@ const IconButton = React.createClass({
|
|||||||
active: React.PropTypes.bool,
|
active: React.PropTypes.bool,
|
||||||
style: React.PropTypes.object,
|
style: React.PropTypes.object,
|
||||||
activeStyle: React.PropTypes.object,
|
activeStyle: React.PropTypes.object,
|
||||||
disabled: React.PropTypes.bool
|
disabled: React.PropTypes.bool,
|
||||||
|
animate: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultProps () {
|
getDefaultProps () {
|
||||||
return {
|
return {
|
||||||
size: 18,
|
size: 18,
|
||||||
active: false,
|
active: false,
|
||||||
disabled: false
|
disabled: false,
|
||||||
|
animate: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -49,9 +52,18 @@ const IconButton = React.createClass({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>
|
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||||
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
|
{({ rotate }) =>
|
||||||
</button>
|
<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: {
|
propTypes: {
|
||||||
isVisible: React.PropTypes.bool,
|
isVisible: React.PropTypes.bool,
|
||||||
onOverlayClicked: React.PropTypes.func,
|
onOverlayClicked: React.PropTypes.func,
|
||||||
onCloseClicked: React.PropTypes.func
|
onCloseClicked: React.PropTypes.func,
|
||||||
|
intl: React.PropTypes.object.isRequired,
|
||||||
|
children: React.PropTypes.node
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@ -57,19 +59,17 @@ const Lightbox = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
|
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
|
||||||
|
|
||||||
const content = isVisible ? children : <div />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}>
|
<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 }) }}>
|
||||||
<Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}>
|
{({ backgroundOpacity, opacity, y }) =>
|
||||||
{({ 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)`}}>
|
<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} />
|
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
|
||||||
{content}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</Motion>
|
}
|
||||||
</div>
|
</Motion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const LoadingIndicator = () => {
|
const style = {
|
||||||
const style = {
|
textAlign: 'center',
|
||||||
textAlign: 'center',
|
fontSize: '16px',
|
||||||
fontSize: '16px',
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: '#616b86',
|
||||||
color: '#616b86',
|
paddingTop: '120px'
|
||||||
paddingTop: '120px'
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LoadingIndicator = () => (
|
||||||
|
<div style={style}>
|
||||||
|
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default LoadingIndicator;
|
export default LoadingIndicator;
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
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 = {
|
const outerStyle = {
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'border-box'
|
boxSizing: 'border-box',
|
||||||
|
position: 'relative'
|
||||||
};
|
};
|
||||||
|
|
||||||
const spoilerStyle = {
|
const spoilerStyle = {
|
||||||
@ -32,11 +38,18 @@ const spoilerSubSpanStyle = {
|
|||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spoilerButtonStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '6px',
|
||||||
|
left: '8px',
|
||||||
|
zIndex: '100'
|
||||||
|
};
|
||||||
|
|
||||||
const MediaGallery = React.createClass({
|
const MediaGallery = React.createClass({
|
||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
return {
|
return {
|
||||||
visible: false
|
visible: !this.props.sensitive
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -59,21 +72,30 @@ const MediaGallery = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleOpen () {
|
handleOpen () {
|
||||||
this.setState({ visible: true });
|
this.setState({ visible: !this.state.visible });
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, sensitive } = this.props;
|
const { media, intl, sensitive } = this.props;
|
||||||
|
|
||||||
let children;
|
let children;
|
||||||
|
|
||||||
if (sensitive && !this.state.visible) {
|
if (!this.state.visible) {
|
||||||
children = (
|
if (sensitive) {
|
||||||
<div style={spoilerStyle} onClick={this.handleOpen}>
|
children = (
|
||||||
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
<div style={spoilerStyle} onClick={this.handleOpen}>
|
||||||
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||||
</div>
|
<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 {
|
} else {
|
||||||
const size = media.take(4).size;
|
const size = media.take(4).size;
|
||||||
|
|
||||||
@ -134,9 +156,12 @@ const MediaGallery = React.createClass({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
|
<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}
|
{children}
|
||||||
</div>
|
</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 {
|
import { injectIntl, FormattedRelative } from 'react-intl';
|
||||||
FormattedMessage,
|
|
||||||
FormattedDate,
|
|
||||||
FormattedRelative
|
|
||||||
} from 'react-intl';
|
|
||||||
|
|
||||||
const RelativeTimestamp = ({ timestamp }) => {
|
const RelativeTimestamp = ({ intl, timestamp }) => {
|
||||||
return <FormattedRelative value={new Date(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 = {
|
RelativeTimestamp.propTypes = {
|
||||||
|
intl: React.PropTypes.object.isRequired,
|
||||||
timestamp: React.PropTypes.string.isRequired
|
timestamp: React.PropTypes.string.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RelativeTimestamp;
|
export default injectIntl(RelativeTimestamp);
|
||||||
|
@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleMentionClick () {
|
handleMentionClick () {
|
||||||
this.props.onMention(this.props.status.get('account'));
|
this.props.onMention(this.props.status.get('account'), this.context.router);
|
||||||
},
|
},
|
||||||
|
|
||||||
handleBlockClick () {
|
handleBlockClick () {
|
||||||
@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({
|
|||||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
<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 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 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' }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import emojify from '../emoji';
|
import emojify from '../emoji';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const StatusContent = React.createClass({
|
const StatusContent = React.createClass({
|
||||||
|
|
||||||
@ -13,6 +14,12 @@ const StatusContent = React.createClass({
|
|||||||
onClick: React.PropTypes.func
|
onClick: React.PropTypes.func
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getInitialState () {
|
||||||
|
return {
|
||||||
|
hidden: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
@ -31,8 +38,6 @@ const StatusContent = React.createClass({
|
|||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
link.setAttribute('rel', 'noopener');
|
link.setAttribute('rel', 'noopener');
|
||||||
}
|
}
|
||||||
|
|
||||||
link.addEventListener('click', this.onNormalClick, false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -52,16 +57,59 @@ const StatusContent = React.createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onNormalClick (e) {
|
handleMouseDown (e) {
|
||||||
e.stopPropagation();
|
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 () {
|
render () {
|
||||||
const { status, onClick } = this.props;
|
const { status } = this.props;
|
||||||
|
const { hidden } = this.state;
|
||||||
|
|
||||||
const content = { __html: emojify(status.get('content')) };
|
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,
|
onScrollToBottom: React.PropTypes.func,
|
||||||
onScrollToTop: React.PropTypes.func,
|
onScrollToTop: React.PropTypes.func,
|
||||||
onScroll: React.PropTypes.func,
|
onScroll: React.PropTypes.func,
|
||||||
trackScroll: React.PropTypes.bool
|
trackScroll: React.PropTypes.bool,
|
||||||
|
isLoading: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultProps () {
|
getDefaultProps () {
|
||||||
@ -24,10 +25,10 @@ const StatusList = React.createClass({
|
|||||||
|
|
||||||
handleScroll (e) {
|
handleScroll (e) {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
|
const offset = scrollHeight - scrollTop - clientHeight;
|
||||||
this._oldScrollPosition = scrollHeight - scrollTop;
|
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||||
|
|
||||||
if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
|
if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
|
||||||
this.props.onScrollToBottom();
|
this.props.onScrollToBottom();
|
||||||
} else if (scrollTop < 100 && this.props.onScrollToTop) {
|
} else if (scrollTop < 100 && this.props.onScrollToTop) {
|
||||||
this.props.onScrollToTop();
|
this.props.onScrollToTop();
|
||||||
@ -36,21 +37,37 @@ const StatusList = React.createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidMount () {
|
||||||
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
|
this.attachScrollListener();
|
||||||
const node = ReactDOM.findDOMNode(this);
|
},
|
||||||
|
|
||||||
if (node.scrollTop > 0) {
|
componentDidUpdate (prevProps) {
|
||||||
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
|
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 () {
|
render () {
|
||||||
const { statusIds, onScrollToBottom, trackScroll } = this.props;
|
const { statusIds, onScrollToBottom, trackScroll } = this.props;
|
||||||
|
|
||||||
const scrollableArea = (
|
const scrollableArea = (
|
||||||
<div className='scrollable' onScroll={this.handleScroll}>
|
<div className='scrollable' ref={this.setRef}>
|
||||||
<div>
|
<div>
|
||||||
{statusIds.map((statusId) => {
|
{statusIds.map((statusId) => {
|
||||||
return <StatusContainer key={statusId} id={statusId} />;
|
return <StatusContainer key={statusId} id={statusId} />;
|
||||||
|
@ -4,7 +4,8 @@ import IconButton from './icon_button';
|
|||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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 = {
|
const videoStyle = {
|
||||||
@ -20,7 +21,7 @@ const videoStyle = {
|
|||||||
const muteStyle = {
|
const muteStyle = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '10px',
|
top: '10px',
|
||||||
left: '10px',
|
right: '10px',
|
||||||
opacity: '0.8',
|
opacity: '0.8',
|
||||||
zIndex: '5'
|
zIndex: '5'
|
||||||
};
|
};
|
||||||
@ -35,7 +36,8 @@ const spoilerStyle = {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
};
|
};
|
||||||
|
|
||||||
const spoilerSpanStyle = {
|
const spoilerSpanStyle = {
|
||||||
@ -49,6 +51,13 @@ const spoilerSubSpanStyle = {
|
|||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spoilerButtonStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '6px',
|
||||||
|
left: '8px',
|
||||||
|
zIndex: '100'
|
||||||
|
};
|
||||||
|
|
||||||
const VideoPlayer = React.createClass({
|
const VideoPlayer = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({
|
|||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
return {
|
return {
|
||||||
visible: false,
|
visible: !this.props.sensitive,
|
||||||
|
preview: true,
|
||||||
muted: true
|
muted: true
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleOpen () {
|
handleOpen () {
|
||||||
this.setState({ visible: true });
|
this.setState({ preview: !this.state.preview });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleVisibility () {
|
||||||
|
this.setState({
|
||||||
|
visible: !this.state.visible,
|
||||||
|
preview: true
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, intl, width, height, sensitive } = this.props;
|
const { media, intl, width, height, sensitive } = this.props;
|
||||||
|
|
||||||
if (sensitive && !this.state.visible) {
|
let spoilerButton = (
|
||||||
return (
|
<div style={spoilerButtonStyle} >
|
||||||
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
|
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
|
||||||
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
</div>
|
||||||
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
);
|
||||||
</div>
|
|
||||||
);
|
if (!this.state.visible) {
|
||||||
} else if (!sensitive && !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 (
|
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}>
|
<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 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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -113,7 +150,8 @@ const VideoPlayer = React.createClass({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
<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} />
|
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors';
|
|||||||
import Account from '../components/account';
|
import Account from '../components/account';
|
||||||
import {
|
import {
|
||||||
followAccount,
|
followAccount,
|
||||||
unfollowAccount
|
unfollowAccount,
|
||||||
|
blockAccount,
|
||||||
|
unblockAccount
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
} else {
|
} else {
|
||||||
dispatch(followAccount(account.get('id')));
|
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
|
refreshTimeline
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import { updateNotifications } from '../actions/notifications';
|
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 createBrowserHistory from 'history/lib/createBrowserHistory';
|
||||||
import {
|
import {
|
||||||
applyRouterMiddleware,
|
applyRouterMiddleware,
|
||||||
useRouterHistory,
|
useRouterHistory,
|
||||||
Router,
|
Router,
|
||||||
Route,
|
Route,
|
||||||
|
IndexRedirect,
|
||||||
IndexRoute
|
IndexRoute
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { useScroll } from 'react-router-scroll';
|
import { useScroll } from 'react-router-scroll';
|
||||||
@ -35,6 +33,8 @@ import Favourites from '../features/favourites';
|
|||||||
import HashtagTimeline from '../features/hashtag_timeline';
|
import HashtagTimeline from '../features/hashtag_timeline';
|
||||||
import Notifications from '../features/notifications';
|
import Notifications from '../features/notifications';
|
||||||
import FollowRequests from '../features/follow_requests';
|
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 { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
import en from 'react-intl/locale-data/en';
|
import en from 'react-intl/locale-data/en';
|
||||||
import de from 'react-intl/locale-data/de';
|
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 hu from 'react-intl/locale-data/hu';
|
||||||
import uk from 'react-intl/locale-data/uk';
|
import uk from 'react-intl/locale-data/uk';
|
||||||
import getMessagesForLocale from '../locales';
|
import getMessagesForLocale from '../locales';
|
||||||
|
import { hydrateStore } from '../actions/store';
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
|
store.dispatch(hydrateStore(window.INITIAL_STATE));
|
||||||
|
|
||||||
const browserHistory = useRouterHistory(createBrowserHistory)({
|
const browserHistory = useRouterHistory(createBrowserHistory)({
|
||||||
basename: '/web'
|
basename: '/web'
|
||||||
});
|
});
|
||||||
@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
|
|||||||
const Mastodon = React.createClass({
|
const Mastodon = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
token: React.PropTypes.string.isRequired,
|
|
||||||
timelines: React.PropTypes.object,
|
|
||||||
account: React.PropTypes.string,
|
|
||||||
locale: React.PropTypes.string.isRequired
|
locale: React.PropTypes.string.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
const { token, account, locale } = this.props;
|
const { locale } = this.props;
|
||||||
|
|
||||||
store.dispatch(setAccessToken(token));
|
|
||||||
store.dispatch(setAccountSelf(JSON.parse(account)));
|
|
||||||
|
|
||||||
if (typeof App !== 'undefined') {
|
if (typeof App !== 'undefined') {
|
||||||
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.type) {
|
switch(data.type) {
|
||||||
case 'update':
|
case 'update':
|
||||||
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
|
store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
|
||||||
case 'delete':
|
break;
|
||||||
return store.dispatch(deleteFromTimelines(data.id));
|
case 'delete':
|
||||||
case 'notification':
|
store.dispatch(deleteFromTimelines(data.id));
|
||||||
return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
|
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}>
|
<Provider store={store}>
|
||||||
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
|
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
|
||||||
<Route path='/' component={UI}>
|
<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/home' component={HomeTimeline} />
|
||||||
<Route path='timelines/mentions' component={MentionsTimeline} />
|
<Route path='timelines/mentions' component={MentionsTimeline} />
|
||||||
<Route path='timelines/public' component={PublicTimeline} />
|
<Route path='timelines/public' component={PublicTimeline} />
|
||||||
<Route path='timelines/tag/:id' component={HashtagTimeline} />
|
<Route path='timelines/tag/:id' component={HashtagTimeline} />
|
||||||
|
|
||||||
<Route path='notifications' component={Notifications} />
|
<Route path='notifications' component={Notifications} />
|
||||||
|
<Route path='favourites' component={FavouritedStatuses} />
|
||||||
|
|
||||||
<Route path='statuses/new' component={Compose} />
|
<Route path='statuses/new' component={Compose} />
|
||||||
<Route path='statuses/:statusId' component={Status} />
|
<Route path='statuses/:statusId' component={Status} />
|
||||||
@ -128,6 +128,7 @@ const Mastodon = React.createClass({
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path='follow_requests' component={FollowRequests} />
|
<Route path='follow_requests' component={FollowRequests} />
|
||||||
|
<Route path='*' component={GenericNotFound} />
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts';
|
|||||||
import { deleteStatus } from '../actions/statuses';
|
import { deleteStatus } from '../actions/statuses';
|
||||||
import { openMedia } from '../actions/modal';
|
import { openMedia } from '../actions/modal';
|
||||||
import { createSelector } from 'reselect'
|
import { createSelector } from 'reselect'
|
||||||
|
import { isMobile } from '../is_mobile'
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
statusBase: state.getIn(['statuses', props.id]),
|
statusBase: state.getIn(['statuses', props.id]),
|
||||||
@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
dispatch(deleteStatus(status.get('id')));
|
dispatch(deleteStatus(status.get('id')));
|
||||||
},
|
},
|
||||||
|
|
||||||
onMention (account) {
|
onMention (account, router) {
|
||||||
dispatch(mentionCompose(account));
|
dispatch(mentionCompose(account));
|
||||||
|
if (isMobile(window.innerWidth)) {
|
||||||
|
router.push('/statuses/new');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onOpenMedia (url) {
|
onOpenMedia (url) {
|
||||||
|
@ -5,5 +5,5 @@ emojione.sprites = false;
|
|||||||
emojione.imagePathPNG = '/emoji/';
|
emojione.imagePathPNG = '/emoji/';
|
||||||
|
|
||||||
export default function emojify(text) {
|
export default function emojify(text) {
|
||||||
return emojione.unicodeToImage(text);
|
return emojione.toImage(text);
|
||||||
};
|
};
|
||||||
|
@ -66,7 +66,7 @@ const ActionBar = React.createClass({
|
|||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div style={outerDropdownStyle}>
|
<div style={outerDropdownStyle}>
|
||||||
<DropdownMenu items={menu} icon='bars' size={24} />
|
<DropdownMenu items={menu} icon='bars' size={24} direction="right" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={outerLinksStyle}>
|
<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} />
|
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
||||||
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
<div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{info}
|
{info}
|
||||||
{actionBtn}
|
{actionBtn}
|
||||||
|
@ -20,6 +20,7 @@ import LoadingIndicator from '../../components/loading_indicator';
|
|||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import ColumnBackButton from '../../components/column_back_button';
|
import ColumnBackButton from '../../components/column_back_button';
|
||||||
|
import { isMobile } from '../../is_mobile'
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
@ -34,11 +35,16 @@ const makeMapStateToProps = () => {
|
|||||||
|
|
||||||
const Account = React.createClass({
|
const Account = React.createClass({
|
||||||
|
|
||||||
|
contextTypes: {
|
||||||
|
router: React.PropTypes.object
|
||||||
|
},
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
params: React.PropTypes.object.isRequired,
|
params: React.PropTypes.object.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
account: ImmutablePropTypes.map,
|
account: ImmutablePropTypes.map,
|
||||||
me: React.PropTypes.number.isRequired
|
me: React.PropTypes.number.isRequired,
|
||||||
|
children: React.PropTypes.node
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@ -71,6 +77,9 @@ const Account = React.createClass({
|
|||||||
|
|
||||||
handleMention () {
|
handleMention () {
|
||||||
this.props.dispatch(mentionCompose(this.props.account));
|
this.props.dispatch(mentionCompose(this.props.account));
|
||||||
|
if (isMobile(window.innerWidth)) {
|
||||||
|
this.context.router.push('/statuses/new');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -9,7 +9,8 @@ import StatusList from '../../components/status_list';
|
|||||||
import LoadingIndicator from '../../components/loading_indicator';
|
import LoadingIndicator from '../../components/loading_indicator';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
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'])
|
me: state.getIn(['meta', 'me'])
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({
|
|||||||
propTypes: {
|
propTypes: {
|
||||||
params: React.PropTypes.object.isRequired,
|
params: React.PropTypes.object.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
statusIds: ImmutablePropTypes.list
|
statusIds: ImmutablePropTypes.list,
|
||||||
|
isLoading: React.PropTypes.bool,
|
||||||
|
me: React.PropTypes.number.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { statusIds, me } = this.props;
|
const { statusIds, isLoading, me } = this.props;
|
||||||
|
|
||||||
if (!statusIds) {
|
if (!statusIds) {
|
||||||
return <LoadingIndicator />;
|
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({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
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' }
|
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -25,6 +26,8 @@ const ComposeForm = React.createClass({
|
|||||||
suggestion_token: React.PropTypes.string,
|
suggestion_token: React.PropTypes.string,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: ImmutablePropTypes.list,
|
||||||
sensitive: React.PropTypes.bool,
|
sensitive: React.PropTypes.bool,
|
||||||
|
spoiler: React.PropTypes.bool,
|
||||||
|
spoiler_text: React.PropTypes.string,
|
||||||
unlisted: React.PropTypes.bool,
|
unlisted: React.PropTypes.bool,
|
||||||
private: React.PropTypes.bool,
|
private: React.PropTypes.bool,
|
||||||
fileDropDate: React.PropTypes.instanceOf(Date),
|
fileDropDate: React.PropTypes.instanceOf(Date),
|
||||||
@ -32,6 +35,7 @@ const ComposeForm = React.createClass({
|
|||||||
is_uploading: React.PropTypes.bool,
|
is_uploading: React.PropTypes.bool,
|
||||||
in_reply_to: ImmutablePropTypes.map,
|
in_reply_to: ImmutablePropTypes.map,
|
||||||
media_count: React.PropTypes.number,
|
media_count: React.PropTypes.number,
|
||||||
|
me: React.PropTypes.number,
|
||||||
onChange: React.PropTypes.func.isRequired,
|
onChange: React.PropTypes.func.isRequired,
|
||||||
onSubmit: React.PropTypes.func.isRequired,
|
onSubmit: React.PropTypes.func.isRequired,
|
||||||
onCancelReply: React.PropTypes.func.isRequired,
|
onCancelReply: React.PropTypes.func.isRequired,
|
||||||
@ -39,6 +43,8 @@ const ComposeForm = React.createClass({
|
|||||||
onFetchSuggestions: React.PropTypes.func.isRequired,
|
onFetchSuggestions: React.PropTypes.func.isRequired,
|
||||||
onSuggestionSelected: React.PropTypes.func.isRequired,
|
onSuggestionSelected: React.PropTypes.func.isRequired,
|
||||||
onChangeSensitivity: React.PropTypes.func.isRequired,
|
onChangeSensitivity: React.PropTypes.func.isRequired,
|
||||||
|
onChangeSpoilerness: React.PropTypes.func.isRequired,
|
||||||
|
onChangeSpoilerText: React.PropTypes.func.isRequired,
|
||||||
onChangeVisibility: React.PropTypes.func.isRequired,
|
onChangeVisibility: React.PropTypes.func.isRequired,
|
||||||
onChangeListability: React.PropTypes.func.isRequired,
|
onChangeListability: React.PropTypes.func.isRequired,
|
||||||
},
|
},
|
||||||
@ -49,7 +55,7 @@ const ComposeForm = React.createClass({
|
|||||||
this.props.onChange(e.target.value);
|
this.props.onChange(e.target.value);
|
||||||
},
|
},
|
||||||
|
|
||||||
handleKeyUp (e) {
|
handleKeyDown (e) {
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
}
|
}
|
||||||
@ -76,6 +82,15 @@ const ComposeForm = React.createClass({
|
|||||||
this.props.onChangeSensitivity(e.target.checked);
|
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) {
|
handleChangeVisibility (e) {
|
||||||
this.props.onChangeVisibility(e.target.checked);
|
this.props.onChangeVisibility(e.target.checked);
|
||||||
},
|
},
|
||||||
@ -85,7 +100,14 @@ const ComposeForm = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
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();
|
this.autosuggestTextarea.textarea.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -103,8 +125,18 @@ const ComposeForm = React.createClass({
|
|||||||
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
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 (
|
return (
|
||||||
<div style={{ padding: '10px' }}>
|
<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}
|
{replyArea}
|
||||||
|
|
||||||
<AutosuggestTextarea
|
<AutosuggestTextarea
|
||||||
@ -115,7 +147,7 @@ const ComposeForm = React.createClass({
|
|||||||
value={this.props.text}
|
value={this.props.text}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onKeyUp={this.handleKeyUp}
|
onKeyDown={this.handleKeyDown}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
@ -123,7 +155,7 @@ const ComposeForm = React.createClass({
|
|||||||
|
|
||||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
<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' }}><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' }} />
|
<UploadButtonContainer style={{ paddingTop: '4px' }} />
|
||||||
</div>
|
</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>
|
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
|
||||||
</label>
|
</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 }) =>
|
{({ opacity, height }) =>
|
||||||
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
|
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
|
||||||
<Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
|
<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',
|
boxSizing: 'border-box',
|
||||||
background: '#454b5e',
|
|
||||||
padding: '0',
|
padding: '0',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
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 () {
|
const Drawer = ({ children, withHeader, intl }) => {
|
||||||
return (
|
let header = '';
|
||||||
<div className='drawer' style={style}>
|
|
||||||
{this.props.children}
|
if (withHeader) {
|
||||||
|
header = (
|
||||||
|
<div className='drawer__header'>
|
||||||
|
<Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
|
||||||
|
<Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
|
||||||
|
<a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
|
||||||
|
<a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
|
||||||
</div>
|
</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 () {
|
render () {
|
||||||
return (
|
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>
|
<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' }}>
|
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
|
||||||
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -38,7 +38,7 @@ const inputStyle = {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
paddingRight: '30px',
|
paddingRight: '30px',
|
||||||
fontFamily: 'Roboto',
|
fontFamily: 'inherit',
|
||||||
background: '#282c37',
|
background: '#282c37',
|
||||||
color: '#9baec8',
|
color: '#9baec8',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
@ -11,7 +11,9 @@ const UploadButton = React.createClass({
|
|||||||
propTypes: {
|
propTypes: {
|
||||||
disabled: React.PropTypes.bool,
|
disabled: React.PropTypes.bool,
|
||||||
onSelectFile: React.PropTypes.func.isRequired,
|
onSelectFile: React.PropTypes.func.isRequired,
|
||||||
style: React.PropTypes.object
|
style: React.PropTypes.object,
|
||||||
|
resetFileKey: React.PropTypes.number,
|
||||||
|
intl: React.PropTypes.object.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@ -31,12 +33,12 @@ const UploadButton = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl } = this.props;
|
const { intl, resetFileKey, disabled } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={this.props.style}>
|
<div style={this.props.style}>
|
||||||
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} />
|
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
|
||||||
<input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
|
<input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,15 +12,20 @@ const UploadForm = React.createClass({
|
|||||||
propTypes: {
|
propTypes: {
|
||||||
media: ImmutablePropTypes.list.isRequired,
|
media: ImmutablePropTypes.list.isRequired,
|
||||||
is_uploading: React.PropTypes.bool,
|
is_uploading: React.PropTypes.bool,
|
||||||
onRemoveFile: React.PropTypes.func.isRequired
|
onRemoveFile: React.PropTypes.func.isRequired,
|
||||||
|
intl: React.PropTypes.object.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
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 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' }}>
|
<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'))} />
|
<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 (
|
return (
|
||||||
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}>
|
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
|
||||||
{uploads}
|
{uploads}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,8 @@ import {
|
|||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
selectComposeSuggestion,
|
selectComposeSuggestion,
|
||||||
changeComposeSensitivity,
|
changeComposeSensitivity,
|
||||||
|
changeComposeSpoilerness,
|
||||||
|
changeComposeSpoilerText,
|
||||||
changeComposeVisibility,
|
changeComposeVisibility,
|
||||||
changeComposeListability
|
changeComposeListability
|
||||||
} from '../../../actions/compose';
|
} from '../../../actions/compose';
|
||||||
@ -22,13 +24,16 @@ const makeMapStateToProps = () => {
|
|||||||
suggestion_token: state.getIn(['compose', 'suggestion_token']),
|
suggestion_token: state.getIn(['compose', 'suggestion_token']),
|
||||||
suggestions: state.getIn(['compose', 'suggestions']),
|
suggestions: state.getIn(['compose', 'suggestions']),
|
||||||
sensitive: state.getIn(['compose', 'sensitive']),
|
sensitive: state.getIn(['compose', 'sensitive']),
|
||||||
|
spoiler: state.getIn(['compose', 'spoiler']),
|
||||||
|
spoiler_text: state.getIn(['compose', 'spoiler_text']),
|
||||||
unlisted: state.getIn(['compose', 'unlisted']),
|
unlisted: state.getIn(['compose', 'unlisted']),
|
||||||
private: state.getIn(['compose', 'private']),
|
private: state.getIn(['compose', 'private']),
|
||||||
fileDropDate: state.getIn(['compose', 'fileDropDate']),
|
fileDropDate: state.getIn(['compose', 'fileDropDate']),
|
||||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||||
is_uploading: state.getIn(['compose', 'is_uploading']),
|
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||||
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
|
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));
|
dispatch(changeComposeSensitivity(checked));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onChangeSpoilerness (checked) {
|
||||||
|
dispatch(changeComposeSpoilerness(checked));
|
||||||
|
},
|
||||||
|
|
||||||
|
onChangeSpoilerText (checked) {
|
||||||
|
dispatch(changeComposeSpoilerText(checked));
|
||||||
|
},
|
||||||
|
|
||||||
onChangeVisibility (checked) {
|
onChangeVisibility (checked) {
|
||||||
dispatch(changeComposeVisibility(checked));
|
dispatch(changeComposeVisibility(checked));
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import NavigationBar from '../components/navigation_bar';
|
import NavigationBar from '../components/navigation_bar';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => {
|
||||||
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
return {
|
||||||
});
|
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps)(NavigationBar);
|
export default connect(mapStateToProps)(NavigationBar);
|
||||||
|
@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
|
|||||||
|
|
||||||
const mapStateToProps = state => ({
|
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')),
|
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 => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose';
|
|||||||
const Compose = React.createClass({
|
const Compose = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
dispatch: React.PropTypes.func.isRequired
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
|
withHeader: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@ -25,7 +26,7 @@ const Compose = React.createClass({
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<Drawer>
|
<Drawer withHeader={this.props.withHeader}>
|
||||||
<SearchContainer />
|
<SearchContainer />
|
||||||
<NavigationContainer />
|
<NavigationContainer />
|
||||||
<ComposeFormContainer />
|
<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({
|
const messages = defineMessages({
|
||||||
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||||
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
|
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
|
||||||
settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' },
|
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }
|
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 => ({
|
const mapStateToProps = state => ({
|
||||||
me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
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 }) => {
|
const GettingStarted = ({ intl, me }) => {
|
||||||
let followRequests = '';
|
let followRequests = '';
|
||||||
|
|
||||||
@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => {
|
|||||||
return (
|
return (
|
||||||
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
|
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
|
||||||
<div style={{ position: 'relative' }}>
|
<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='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}
|
{followRequests}
|
||||||
|
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='static-content'>
|
<div className='scrollable optionally-scrollable'>
|
||||||
<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>
|
<div className='static-content getting-started'>
|
||||||
<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_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_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></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>
|
||||||
|
|
||||||
<div className='getting-started__illustration' />
|
|
||||||
</Column>
|
</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 PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import StatusListContainer from '../ui/containers/status_list_container';
|
import StatusListContainer from '../ui/containers/status_list_container';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import { refreshTimeline } from '../../actions/timelines';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' }
|
title: { id: 'column.home', defaultMessage: 'Home' }
|
||||||
@ -12,20 +11,17 @@ const messages = defineMessages({
|
|||||||
const HomeTimeline = React.createClass({
|
const HomeTimeline = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
dispatch: React.PropTypes.func.isRequired
|
intl: React.PropTypes.object.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
componentWillMount () {
|
|
||||||
this.props.dispatch(refreshTimeline('home'));
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl } = this.props;
|
const { intl } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column icon='home' heading={intl.formatMessage(messages.title)}>
|
<Column icon='home' heading={intl.formatMessage(messages.title)}>
|
||||||
|
<ColumnSettingsContainer />
|
||||||
<StatusListContainer {...this.props} type='home' />
|
<StatusListContainer {...this.props} type='home' />
|
||||||
</Column>
|
</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 PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Toggle from 'react-toggle';
|
|
||||||
import { Motion, spring } from 'react-motion';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import ColumnCollapsable from '../../../components/column_collapsable';
|
||||||
|
import SettingToggle from './setting_toggle';
|
||||||
|
|
||||||
const outerStyle = {
|
const outerStyle = {
|
||||||
background: '#373b4a',
|
background: '#373b4a',
|
||||||
padding: '15px'
|
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 = {
|
const sectionStyle = {
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({
|
|||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
settings: ImmutablePropTypes.map.isRequired,
|
settings: ImmutablePropTypes.map.isRequired,
|
||||||
onChange: React.PropTypes.func.isRequired
|
onChange: React.PropTypes.func.isRequired,
|
||||||
},
|
onSave: React.PropTypes.func.isRequired
|
||||||
|
|
||||||
getInitialState () {
|
|
||||||
return {
|
|
||||||
collapsed: true
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
handleToggleCollapsed () {
|
|
||||||
this.setState({ collapsed: !this.state.collapsed });
|
|
||||||
},
|
|
||||||
|
|
||||||
handleChange (key, e) {
|
|
||||||
this.props.onChange(key, e.target.checked);
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { settings } = this.props;
|
const { settings, onChange, onSave } = this.props;
|
||||||
const { collapsed } = this.state;
|
|
||||||
|
|
||||||
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
||||||
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
||||||
|
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
|
||||||
<div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div>
|
<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) }}>
|
<div style={rowStyle}>
|
||||||
{({ opacity, height }) =>
|
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
|
||||||
<div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
|
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
|
||||||
<div style={outerStyle}>
|
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
|
||||||
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
|
</div>
|
||||||
|
|
||||||
<div style={rowStyle}>
|
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
|
||||||
<label style={labelStyle}>
|
|
||||||
<Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} />
|
|
||||||
<span style={labelSpanStyle}>{alertStr}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
<div style={rowStyle}>
|
||||||
<Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} />
|
<SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
|
||||||
<span style={labelSpanStyle}>{showStr}</span>
|
<SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
|
||||||
</label>
|
<SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
|
||||||
</div>
|
</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}>
|
<div style={rowStyle}>
|
||||||
<label style={labelStyle}>
|
<SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
|
||||||
<Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} />
|
<SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
|
||||||
<span style={labelSpanStyle}>{alertStr}</span>
|
<SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<label style={labelStyle}>
|
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
|
||||||
<Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} />
|
|
||||||
<span style={labelSpanStyle}>{showStr}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
|
<div style={rowStyle}>
|
||||||
|
<SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
|
||||||
<div style={rowStyle}>
|
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
|
||||||
<label style={labelStyle}>
|
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
|
||||||
<Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} />
|
</div>
|
||||||
<span style={labelSpanStyle}>{alertStr}</span>
|
</div>
|
||||||
</label>
|
</ColumnCollapsable>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container';
|
|||||||
import AccountContainer from '../../../containers/account_container';
|
import AccountContainer from '../../../containers/account_container';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import Permalink from '../../../components/permalink';
|
import Permalink from '../../../components/permalink';
|
||||||
|
import emojify from '../../../emoji';
|
||||||
|
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
|
||||||
|
|
||||||
const messageStyle = {
|
const messageStyle = {
|
||||||
marginLeft: '68px',
|
marginLeft: '68px',
|
||||||
@ -71,7 +73,7 @@ const Notification = React.createClass({
|
|||||||
<i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
|
<i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<StatusContainer id={notification.get('status')} muted={true} />
|
<StatusContainer id={notification.get('status')} muted={true} />
|
||||||
@ -83,7 +85,8 @@ const Notification = React.createClass({
|
|||||||
const { notification } = this.props;
|
const { notification } = this.props;
|
||||||
const account = notification.get('account');
|
const account = notification.get('account');
|
||||||
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
|
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')) {
|
switch(notification.get('type')) {
|
||||||
case 'follow':
|
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 { connect } from 'react-redux';
|
||||||
import ColumnSettings from '../components/column_settings';
|
import ColumnSettings from '../components/column_settings';
|
||||||
import { changeNotificationsSetting } from '../../../actions/notifications';
|
import { changeSetting, saveSettings } from '../../../actions/settings';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
settings: state.getIn(['notifications', 'settings'])
|
settings: state.getIn(['settings', 'notifications'])
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
||||||
onChange (key, checked) {
|
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 PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import {
|
import { expandNotifications } from '../../actions/notifications';
|
||||||
refreshNotifications,
|
|
||||||
expandNotifications
|
|
||||||
} from '../../actions/notifications';
|
|
||||||
import NotificationContainer from './containers/notification_container';
|
import NotificationContainer from './containers/notification_container';
|
||||||
import { ScrollContainer } from 'react-router-scroll';
|
import { ScrollContainer } from 'react-router-scroll';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
@ -18,12 +15,13 @@ const messages = defineMessages({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getNotifications = createSelector([
|
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'])
|
state => state.getIn(['notifications', 'items'])
|
||||||
], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
|
], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
notifications: getNotifications(state)
|
notifications: getNotifications(state),
|
||||||
|
isLoading: state.getIn(['notifications', 'isLoading'], true)
|
||||||
});
|
});
|
||||||
|
|
||||||
const Notifications = React.createClass({
|
const Notifications = React.createClass({
|
||||||
@ -32,7 +30,8 @@ const Notifications = React.createClass({
|
|||||||
notifications: ImmutablePropTypes.list.isRequired,
|
notifications: ImmutablePropTypes.list.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
trackScroll: React.PropTypes.bool,
|
trackScroll: React.PropTypes.bool,
|
||||||
intl: React.PropTypes.object.isRequired
|
intl: React.PropTypes.object.isRequired,
|
||||||
|
isLoading: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultProps () {
|
getDefaultProps () {
|
||||||
@ -43,15 +42,11 @@ const Notifications = React.createClass({
|
|||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
componentWillMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(refreshNotifications());
|
|
||||||
},
|
|
||||||
|
|
||||||
handleScroll (e) {
|
handleScroll (e) {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
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());
|
this.props.dispatch(expandNotifications());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -70,6 +65,7 @@ const Notifications = React.createClass({
|
|||||||
if (trackScroll) {
|
if (trackScroll) {
|
||||||
return (
|
return (
|
||||||
<Column icon='bell' heading={intl.formatMessage(messages.title)}>
|
<Column icon='bell' heading={intl.formatMessage(messages.title)}>
|
||||||
|
<ColumnSettingsContainer />
|
||||||
<ScrollContainer scrollKey='notifications'>
|
<ScrollContainer scrollKey='notifications'>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</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={{ 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 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 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' }}><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} /></div>
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
|
||||||
</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 VideoPlayer from '../../../components/video_player';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { FormattedDate, FormattedNumber } from 'react-intl';
|
import { FormattedDate, FormattedNumber } from 'react-intl';
|
||||||
|
import CardContainer from '../containers/card_container';
|
||||||
|
|
||||||
const DetailedStatus = React.createClass({
|
const DetailedStatus = React.createClass({
|
||||||
|
|
||||||
@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
|
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.get('media_attachments').size > 0) {
|
||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({
|
|||||||
} else {
|
} else {
|
||||||
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
|
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 (
|
return (
|
||||||
@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({
|
|||||||
{media}
|
{media}
|
||||||
|
|
||||||
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
|
<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>
|
||||||
</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 ColumnBackButton from '../../components/column_back_button';
|
||||||
import StatusContainer from '../../containers/status_container';
|
import StatusContainer from '../../containers/status_container';
|
||||||
import { openMedia } from '../../actions/modal';
|
import { openMedia } from '../../actions/modal';
|
||||||
|
import { isMobile } from '../../is_mobile'
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
@ -47,7 +48,8 @@ const Status = React.createClass({
|
|||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
ancestorsIds: ImmutablePropTypes.list,
|
ancestorsIds: ImmutablePropTypes.list,
|
||||||
descendantsIds: ImmutablePropTypes.list
|
descendantsIds: ImmutablePropTypes.list,
|
||||||
|
me: React.PropTypes.number
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@ -80,6 +82,10 @@ const Status = React.createClass({
|
|||||||
|
|
||||||
handleMentionClick (account) {
|
handleMentionClick (account) {
|
||||||
this.props.dispatch(mentionCompose(account));
|
this.props.dispatch(mentionCompose(account));
|
||||||
|
|
||||||
|
if (isMobile(window.innerWidth)) {
|
||||||
|
this.context.router.push('/statuses/new');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOpenMedia (url) {
|
handleOpenMedia (url) {
|
||||||
|
@ -13,10 +13,10 @@ const iconStyle = {
|
|||||||
marginRight: '5px'
|
marginRight: '5px'
|
||||||
};
|
};
|
||||||
|
|
||||||
const ColumnLink = ({ icon, text, to, href }) => {
|
const ColumnLink = ({ icon, text, to, href, method }) => {
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
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} />
|
<i className={`fa fa-fw fa-${icon}`} style={iconStyle} />
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
|
@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
const outerStyle = {
|
const outerStyle = {
|
||||||
background: '#373b4a',
|
background: '#373b4a',
|
||||||
margin: '10px',
|
|
||||||
flex: '0 0 auto',
|
flex: '0 0 auto',
|
||||||
marginBottom: '0'
|
overflowY: 'auto'
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabStyle = {
|
const tabStyle = {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
padding: '10px',
|
padding: '10px 5px',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
textAlign: 'center',
|
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='/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='/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='/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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { closeModal } from '../../../actions/modal';
|
import { closeModal } from '../../../actions/modal';
|
||||||
import Lightbox from '../../../components/lightbox';
|
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 => ({
|
const mapStateToProps = state => ({
|
||||||
url: state.getIn(['modal', 'url']),
|
url: state.getIn(['modal', 'url']),
|
||||||
@ -23,6 +26,18 @@ const imageStyle = {
|
|||||||
maxHeight: '80vh'
|
maxHeight: '80vh'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadingStyle = {
|
||||||
|
background: '#373b4a',
|
||||||
|
width: '400px',
|
||||||
|
paddingBottom: '120px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const preloader = () => (
|
||||||
|
<div style={loadingStyle}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Modal = React.createClass({
|
const Modal = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
@ -32,12 +47,18 @@ const Modal = React.createClass({
|
|||||||
onOverlayClicked: React.PropTypes.func
|
onOverlayClicked: React.PropTypes.func
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { url, ...other } = this.props;
|
const { url, ...other } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Lightbox {...other}>
|
<Lightbox {...other}>
|
||||||
<img src={url} style={imageStyle} />
|
<ImageLoader
|
||||||
|
src={url}
|
||||||
|
preloader={preloader}
|
||||||
|
imgProps={{ style: imageStyle }}
|
||||||
|
/>
|
||||||
</Lightbox>
|
</Lightbox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,26 +2,56 @@ import { connect } from 'react-redux';
|
|||||||
import StatusList from '../../../components/status_list';
|
import StatusList from '../../../components/status_list';
|
||||||
import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
|
import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
|
||||||
import Immutable from 'immutable';
|
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) => ({
|
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) {
|
const mapDispatchToProps = (dispatch, { type, id }) => ({
|
||||||
return {
|
|
||||||
onScrollToBottom () {
|
|
||||||
dispatch(scrollTopTimeline(props.type, false));
|
|
||||||
dispatch(expandTimeline(props.type, props.id));
|
|
||||||
},
|
|
||||||
|
|
||||||
onScrollToTop () {
|
onScrollToBottom () {
|
||||||
dispatch(scrollTopTimeline(props.type, true));
|
dispatch(scrollTopTimeline(type, false));
|
||||||
},
|
dispatch(expandTimeline(type, id));
|
||||||
|
},
|
||||||
|
|
||||||
onScroll () {
|
onScrollToTop () {
|
||||||
dispatch(scrollTopTimeline(props.type, false));
|
dispatch(scrollTopTimeline(type, true));
|
||||||
}
|
},
|
||||||
};
|
|
||||||
};
|
onScroll () {
|
||||||
|
dispatch(scrollTopTimeline(type, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
|
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
|
||||||
|
@ -8,12 +8,20 @@ import Compose from '../compose';
|
|||||||
import TabsBar from './components/tabs_bar';
|
import TabsBar from './components/tabs_bar';
|
||||||
import ModalContainer from './containers/modal_container';
|
import ModalContainer from './containers/modal_container';
|
||||||
import Notifications from '../notifications';
|
import Notifications from '../notifications';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { isMobile } from '../../is_mobile';
|
||||||
import { debounce } from 'react-decoration';
|
import { debounce } from 'react-decoration';
|
||||||
import { uploadCompose } from '../../actions/compose';
|
import { uploadCompose } from '../../actions/compose';
|
||||||
import { connect } from 'react-redux';
|
import { refreshTimeline } from '../../actions/timelines';
|
||||||
|
import { refreshNotifications } from '../../actions/notifications';
|
||||||
|
|
||||||
const UI = React.createClass({
|
const UI = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
|
children: React.PropTypes.node
|
||||||
|
},
|
||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
return {
|
return {
|
||||||
width: window.innerWidth
|
width: window.innerWidth
|
||||||
@ -41,7 +49,7 @@ const UI = React.createClass({
|
|||||||
handleDrop (e) {
|
handleDrop (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (e.dataTransfer) {
|
if (e.dataTransfer && e.dataTransfer.files.length === 1) {
|
||||||
this.props.dispatch(uploadCompose(e.dataTransfer.files));
|
this.props.dispatch(uploadCompose(e.dataTransfer.files));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -50,6 +58,9 @@ const UI = React.createClass({
|
|||||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||||
window.addEventListener('dragover', this.handleDragOver);
|
window.addEventListener('dragover', this.handleDragOver);
|
||||||
window.addEventListener('drop', this.handleDrop);
|
window.addEventListener('drop', this.handleDrop);
|
||||||
|
|
||||||
|
this.props.dispatch(refreshTimeline('home'));
|
||||||
|
this.props.dispatch(refreshNotifications());
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
@ -59,11 +70,9 @@ const UI = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const layoutBreakpoint = 1024;
|
|
||||||
|
|
||||||
let mountedColumns;
|
let mountedColumns;
|
||||||
|
|
||||||
if (this.state.width <= layoutBreakpoint) {
|
if (isMobile(this.state.width)) {
|
||||||
mountedColumns = (
|
mountedColumns = (
|
||||||
<ColumnsArea>
|
<ColumnsArea>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
@ -72,7 +81,7 @@ const UI = React.createClass({
|
|||||||
} else {
|
} else {
|
||||||
mountedColumns = (
|
mountedColumns = (
|
||||||
<ColumnsArea>
|
<ColumnsArea>
|
||||||
<Compose />
|
<Compose withHeader={true} />
|
||||||
<HomeTimeline trackScroll={false} />
|
<HomeTimeline trackScroll={false} />
|
||||||
<Notifications trackScroll={false} />
|
<Notifications trackScroll={false} />
|
||||||
{this.props.children}
|
{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.reblog": "Teilen",
|
||||||
"status.favourite": "Favorisieren",
|
"status.favourite": "Favorisieren",
|
||||||
"status.reblogged_by": "{name} teilte",
|
"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",
|
"video_player.toggle_sound": "Ton umschalten",
|
||||||
"account.mention": "Erwähnen",
|
"account.mention": "Erwähnen",
|
||||||
"account.edit_profile": "Profil bearbeiten",
|
"account.edit_profile": "Profil bearbeiten",
|
||||||
@ -19,14 +22,17 @@ const en = {
|
|||||||
"account.follows": "Folgt",
|
"account.follows": "Folgt",
|
||||||
"account.followers": "Folger",
|
"account.followers": "Folger",
|
||||||
"account.follows_you": "Folgt dir",
|
"account.follows_you": "Folgt dir",
|
||||||
|
"account.requested": "Warte auf Erlaubnis",
|
||||||
"getting_started.heading": "Erste Schritte",
|
"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_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_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.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.home": "Home",
|
||||||
"column.mentions": "Erwähnungen",
|
"column.mentions": "Erwähnungen",
|
||||||
"column.public": "Gesamtes Bekanntes Netz",
|
"column.public": "Gesamtes Bekanntes Netz",
|
||||||
"column.notifications": "Mitteilungen",
|
"column.notifications": "Mitteilungen",
|
||||||
|
"column.follow_requests": "Folgeanfragen",
|
||||||
"tabs_bar.compose": "Schreiben",
|
"tabs_bar.compose": "Schreiben",
|
||||||
"tabs_bar.home": "Home",
|
"tabs_bar.home": "Home",
|
||||||
"tabs_bar.mentions": "Erwähnungen",
|
"tabs_bar.mentions": "Erwähnungen",
|
||||||
@ -36,9 +42,12 @@ const en = {
|
|||||||
"compose_form.publish": "Veröffentlichen",
|
"compose_form.publish": "Veröffentlichen",
|
||||||
"compose_form.sensitive": "Medien als sensitiv markieren",
|
"compose_form.sensitive": "Medien als sensitiv markieren",
|
||||||
"compose_form.unlisted": "Öffentlich nicht auflisten",
|
"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.public_timeline": "Öffentlich",
|
||||||
"navigation_bar.logout": "Abmelden",
|
"navigation_bar.logout": "Abmelden",
|
||||||
|
"navigation_bar.follow_requests": "Folgeanfragen",
|
||||||
"reply_indicator.cancel": "Abbrechen",
|
"reply_indicator.cancel": "Abbrechen",
|
||||||
"search.placeholder": "Suche",
|
"search.placeholder": "Suche",
|
||||||
"search.account": "Konto",
|
"search.account": "Konto",
|
||||||
@ -48,7 +57,21 @@ const en = {
|
|||||||
"notification.follow": "{name} folgt dir",
|
"notification.follow": "{name} folgt dir",
|
||||||
"notification.favourite": "{name} favorisierte deinen Status",
|
"notification.favourite": "{name} favorisierte deinen Status",
|
||||||
"notification.reblog": "{name} teilte 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;
|
export default en;
|
||||||
|
@ -17,7 +17,6 @@ const en = {
|
|||||||
"account.unfollow": "Unfollow",
|
"account.unfollow": "Unfollow",
|
||||||
"account.block": "Block",
|
"account.block": "Block",
|
||||||
"account.follow": "Follow",
|
"account.follow": "Follow",
|
||||||
"account.block": "Block",
|
|
||||||
"account.posts": "Posts",
|
"account.posts": "Posts",
|
||||||
"account.follows": "Follows",
|
"account.follows": "Follows",
|
||||||
"account.followers": "Followers",
|
"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_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_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.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.home": "Home",
|
||||||
"column.mentions": "Mentions",
|
"column.mentions": "Mentions",
|
||||||
"column.public": "Public",
|
"column.public": "Public",
|
||||||
@ -40,7 +40,9 @@ const en = {
|
|||||||
"compose_form.publish": "Toot",
|
"compose_form.publish": "Toot",
|
||||||
"compose_form.sensitive": "Mark media as sensitive",
|
"compose_form.sensitive": "Mark media as sensitive",
|
||||||
"compose_form.private": "Mark as private",
|
"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.public_timeline": "Public timeline",
|
||||||
"navigation_bar.logout": "Logout",
|
"navigation_bar.logout": "Logout",
|
||||||
"reply_indicator.cancel": "Cancel",
|
"reply_indicator.cancel": "Cancel",
|
||||||
|
@ -37,7 +37,8 @@ const es = {
|
|||||||
"compose_form.publish": "Publicar",
|
"compose_form.publish": "Publicar",
|
||||||
"compose_form.sensitive": "Marcar el contenido como sensible",
|
"compose_form.sensitive": "Marcar el contenido como sensible",
|
||||||
"compose_form.unlisted": "Privado",
|
"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.public_timeline": "Público",
|
||||||
"navigation_bar.logout": "Cerrar sesión",
|
"navigation_bar.logout": "Cerrar sesión",
|
||||||
"reply_indicator.cancel": "Cancelar",
|
"reply_indicator.cancel": "Cancelar",
|
||||||
|
@ -38,7 +38,8 @@ const fr = {
|
|||||||
"compose_form.publish": "Pouet",
|
"compose_form.publish": "Pouet",
|
||||||
"compose_form.sensitive": "Marquer le contenu comme délicat",
|
"compose_form.sensitive": "Marquer le contenu comme délicat",
|
||||||
"compose_form.unlisted": "Ne pas apparaître dans le fil public",
|
"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.public_timeline": "Public",
|
||||||
"navigation_bar.logout": "Déconnexion",
|
"navigation_bar.logout": "Déconnexion",
|
||||||
"reply_indicator.cancel": "Annuler",
|
"reply_indicator.cancel": "Annuler",
|
||||||
|
@ -38,7 +38,8 @@ const hu = {
|
|||||||
"compose_form.publish": "Tülk!",
|
"compose_form.publish": "Tülk!",
|
||||||
"compose_form.sensitive": "Tartalom érzékenynek jelölése",
|
"compose_form.sensitive": "Tartalom érzékenynek jelölése",
|
||||||
"compose_form.unlisted": "Listázatlan mód",
|
"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.public_timeline": "Nyilvános időfolyam",
|
||||||
"navigation_bar.logout": "Kijelentkezés",
|
"navigation_bar.logout": "Kijelentkezés",
|
||||||
"reply_indicator.cancel": "Mégsem",
|
"reply_indicator.cancel": "Mégsem",
|
||||||
|
@ -36,7 +36,8 @@ const pt = {
|
|||||||
"compose_form.publish": "Publicar",
|
"compose_form.publish": "Publicar",
|
||||||
"compose_form.sensitive": "Marcar conteúdo como sensível",
|
"compose_form.sensitive": "Marcar conteúdo como sensível",
|
||||||
"compose_form.unlisted": "Modo não-listado",
|
"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.public_timeline": "Timeline Pública",
|
||||||
"navigation_bar.logout": "Logout",
|
"navigation_bar.logout": "Logout",
|
||||||
"reply_indicator.cancel": "Cancelar",
|
"reply_indicator.cancel": "Cancelar",
|
||||||
|
@ -38,7 +38,8 @@ const uk = {
|
|||||||
"compose_form.publish": "Дмухнути",
|
"compose_form.publish": "Дмухнути",
|
||||||
"compose_form.sensitive": "Непристойний зміст",
|
"compose_form.sensitive": "Непристойний зміст",
|
||||||
"compose_form.unlisted": "Таємний режим",
|
"compose_form.unlisted": "Таємний режим",
|
||||||
"navigation_bar.settings": "Налаштування",
|
"navigation_bar.edit_profile": "Редагувати профіль",
|
||||||
|
"navigation_bar.preferences": "Налаштування",
|
||||||
"navigation_bar.public_timeline": "Публічна стіна",
|
"navigation_bar.public_timeline": "Публічна стіна",
|
||||||
"navigation_bar.logout": "Вийти",
|
"navigation_bar.logout": "Вийти",
|
||||||
"reply_indicator.cancel": "Відмінити",
|
"reply_indicator.cancel": "Відмінити",
|
||||||
|
@ -23,7 +23,7 @@ export default function errorsMiddleware() {
|
|||||||
dispatch(showAlert(title, message));
|
dispatch(showAlert(title, message));
|
||||||
} else {
|
} else {
|
||||||
console.error(action.error);
|
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 {
|
import {
|
||||||
ACCOUNT_SET_SELF,
|
|
||||||
ACCOUNT_FETCH_SUCCESS,
|
ACCOUNT_FETCH_SUCCESS,
|
||||||
FOLLOWERS_FETCH_SUCCESS,
|
FOLLOWERS_FETCH_SUCCESS,
|
||||||
FOLLOWERS_EXPAND_SUCCESS,
|
FOLLOWERS_EXPAND_SUCCESS,
|
||||||
@ -7,7 +6,9 @@ import {
|
|||||||
FOLLOWING_EXPAND_SUCCESS,
|
FOLLOWING_EXPAND_SUCCESS,
|
||||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
||||||
FOLLOW_REQUESTS_FETCH_SUCCESS
|
FOLLOW_REQUESTS_FETCH_SUCCESS,
|
||||||
|
ACCOUNT_FOLLOW_SUCCESS,
|
||||||
|
ACCOUNT_UNFOLLOW_SUCCESS
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
|
import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
|
||||||
import {
|
import {
|
||||||
@ -33,6 +34,11 @@ import {
|
|||||||
NOTIFICATIONS_REFRESH_SUCCESS,
|
NOTIFICATIONS_REFRESH_SUCCESS,
|
||||||
NOTIFICATIONS_EXPAND_SUCCESS
|
NOTIFICATIONS_EXPAND_SUCCESS
|
||||||
} from '../actions/notifications';
|
} 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';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
|
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) {
|
export default function accounts(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ACCOUNT_SET_SELF:
|
case STORE_HYDRATE:
|
||||||
case ACCOUNT_FETCH_SUCCESS:
|
return state.merge(action.state.get('accounts'));
|
||||||
case NOTIFICATIONS_UPDATE:
|
case ACCOUNT_FETCH_SUCCESS:
|
||||||
return normalizeAccount(state, action.account);
|
case NOTIFICATIONS_UPDATE:
|
||||||
case FOLLOWERS_FETCH_SUCCESS:
|
return normalizeAccount(state, action.account);
|
||||||
case FOLLOWERS_EXPAND_SUCCESS:
|
case FOLLOWERS_FETCH_SUCCESS:
|
||||||
case FOLLOWING_FETCH_SUCCESS:
|
case FOLLOWERS_EXPAND_SUCCESS:
|
||||||
case FOLLOWING_EXPAND_SUCCESS:
|
case FOLLOWING_FETCH_SUCCESS:
|
||||||
case REBLOGS_FETCH_SUCCESS:
|
case FOLLOWING_EXPAND_SUCCESS:
|
||||||
case FAVOURITES_FETCH_SUCCESS:
|
case REBLOGS_FETCH_SUCCESS:
|
||||||
case COMPOSE_SUGGESTIONS_READY:
|
case FAVOURITES_FETCH_SUCCESS:
|
||||||
case SEARCH_SUGGESTIONS_READY:
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
case SEARCH_SUGGESTIONS_READY:
|
||||||
return normalizeAccounts(state, action.accounts);
|
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
||||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
return normalizeAccounts(state, action.accounts);
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||||
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
case TIMELINE_REFRESH_SUCCESS:
|
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
|
||||||
case TIMELINE_EXPAND_SUCCESS:
|
case TIMELINE_REFRESH_SUCCESS:
|
||||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
case TIMELINE_EXPAND_SUCCESS:
|
||||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||||
case CONTEXT_FETCH_SUCCESS:
|
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||||
return normalizeAccountsFromStatuses(state, action.statuses);
|
case CONTEXT_FETCH_SUCCESS:
|
||||||
case REBLOG_SUCCESS:
|
case FAVOURITED_STATUSES_FETCH_SUCCESS:
|
||||||
case FAVOURITE_SUCCESS:
|
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
|
||||||
case UNREBLOG_SUCCESS:
|
return normalizeAccountsFromStatuses(state, action.statuses);
|
||||||
case UNFAVOURITE_SUCCESS:
|
case REBLOG_SUCCESS:
|
||||||
return normalizeAccountFromStatus(state, action.response);
|
case FAVOURITE_SUCCESS:
|
||||||
case TIMELINE_UPDATE:
|
case UNREBLOG_SUCCESS:
|
||||||
case STATUS_FETCH_SUCCESS:
|
case UNFAVOURITE_SUCCESS:
|
||||||
return normalizeAccountFromStatus(state, action.status);
|
return normalizeAccountFromStatus(state, action.response);
|
||||||
default:
|
case TIMELINE_UPDATE:
|
||||||
return state;
|
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_SUGGESTIONS_READY,
|
||||||
COMPOSE_SUGGESTION_SELECT,
|
COMPOSE_SUGGESTION_SELECT,
|
||||||
COMPOSE_SENSITIVITY_CHANGE,
|
COMPOSE_SENSITIVITY_CHANGE,
|
||||||
|
COMPOSE_SPOILERNESS_CHANGE,
|
||||||
|
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||||
COMPOSE_VISIBILITY_CHANGE,
|
COMPOSE_VISIBILITY_CHANGE,
|
||||||
COMPOSE_LISTABILITY_CHANGE
|
COMPOSE_LISTABILITY_CHANGE
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = Immutable.Map({
|
||||||
mounted: false,
|
mounted: false,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
|
spoiler: false,
|
||||||
|
spoiler_text: '',
|
||||||
unlisted: false,
|
unlisted: false,
|
||||||
private: false,
|
private: false,
|
||||||
text: '',
|
text: '',
|
||||||
@ -38,7 +42,8 @@ const initialState = Immutable.Map({
|
|||||||
media_attachments: Immutable.List(),
|
media_attachments: Immutable.List(),
|
||||||
suggestion_token: null,
|
suggestion_token: null,
|
||||||
suggestions: Immutable.List(),
|
suggestions: Immutable.List(),
|
||||||
me: null
|
me: null,
|
||||||
|
resetFileKey: Math.floor((Math.random() * 0x10000))
|
||||||
});
|
});
|
||||||
|
|
||||||
function statusToTextMentions(state, status) {
|
function statusToTextMentions(state, status) {
|
||||||
@ -55,6 +60,8 @@ function statusToTextMentions(state, status) {
|
|||||||
function clearAll(state) {
|
function clearAll(state) {
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('text', '');
|
map.set('text', '');
|
||||||
|
map.set('spoiler', false);
|
||||||
|
map.set('spoiler_text', '');
|
||||||
map.set('is_submitting', false);
|
map.set('is_submitting', false);
|
||||||
map.set('in_reply_to', null);
|
map.set('in_reply_to', null);
|
||||||
map.update('media_attachments', list => list.clear());
|
map.update('media_attachments', list => list.clear());
|
||||||
@ -65,6 +72,7 @@ function appendMedia(state, media) {
|
|||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.update('media_attachments', list => list.push(media));
|
map.update('media_attachments', list => list.push(media));
|
||||||
map.set('is_uploading', false);
|
map.set('is_uploading', false);
|
||||||
|
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
|
||||||
map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
|
map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -80,7 +88,7 @@ function removeMedia(state, mediaId) {
|
|||||||
|
|
||||||
const insertSuggestion = (state, position, token, completion) => {
|
const insertSuggestion = (state, position, token, completion) => {
|
||||||
return state.withMutations(map => {
|
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.set('suggestion_token', null);
|
||||||
map.update('suggestions', Immutable.List(), list => list.clear());
|
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) {
|
export default function compose(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case COMPOSE_MOUNT:
|
case STORE_HYDRATE:
|
||||||
return state.set('mounted', true);
|
return state.merge(action.state.get('compose'));
|
||||||
case COMPOSE_UNMOUNT:
|
case COMPOSE_MOUNT:
|
||||||
return state.set('mounted', false);
|
return state.set('mounted', true);
|
||||||
case COMPOSE_SENSITIVITY_CHANGE:
|
case COMPOSE_UNMOUNT:
|
||||||
return state.set('sensitive', action.checked);
|
return state.set('mounted', false);
|
||||||
case COMPOSE_VISIBILITY_CHANGE:
|
case COMPOSE_SENSITIVITY_CHANGE:
|
||||||
return state.set('private', action.checked);
|
return state.set('sensitive', action.checked);
|
||||||
case COMPOSE_LISTABILITY_CHANGE:
|
case COMPOSE_SPOILERNESS_CHANGE:
|
||||||
return state.set('unlisted', action.checked);
|
return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
|
||||||
case COMPOSE_CHANGE:
|
case COMPOSE_SPOILER_TEXT_CHANGE:
|
||||||
return state.set('text', action.text);
|
return state.set('spoiler_text', action.text);
|
||||||
case COMPOSE_REPLY:
|
case COMPOSE_VISIBILITY_CHANGE:
|
||||||
return state.withMutations(map => {
|
return state.set('private', action.checked);
|
||||||
map.set('in_reply_to', action.status.get('id'));
|
case COMPOSE_LISTABILITY_CHANGE:
|
||||||
map.set('text', statusToTextMentions(state, action.status));
|
return state.set('unlisted', action.checked);
|
||||||
});
|
case COMPOSE_CHANGE:
|
||||||
case COMPOSE_REPLY_CANCEL:
|
return state.set('text', action.text);
|
||||||
return state.withMutations(map => {
|
case COMPOSE_REPLY:
|
||||||
map.set('in_reply_to', null);
|
return state.withMutations(map => {
|
||||||
map.set('text', '');
|
map.set('in_reply_to', action.status.get('id'));
|
||||||
});
|
map.set('text', statusToTextMentions(state, action.status));
|
||||||
case COMPOSE_SUBMIT_REQUEST:
|
});
|
||||||
return state.set('is_submitting', true);
|
case COMPOSE_REPLY_CANCEL:
|
||||||
case COMPOSE_SUBMIT_SUCCESS:
|
return state.withMutations(map => {
|
||||||
return clearAll(state);
|
map.set('in_reply_to', null);
|
||||||
case COMPOSE_SUBMIT_FAIL:
|
map.set('text', '');
|
||||||
return state.set('is_submitting', false);
|
});
|
||||||
case COMPOSE_UPLOAD_REQUEST:
|
case COMPOSE_SUBMIT_REQUEST:
|
||||||
return state.withMutations(map => {
|
return state.set('is_submitting', true);
|
||||||
map.set('is_uploading', true);
|
case COMPOSE_SUBMIT_SUCCESS:
|
||||||
map.set('fileDropDate', new Date());
|
return clearAll(state);
|
||||||
});
|
case COMPOSE_SUBMIT_FAIL:
|
||||||
case COMPOSE_UPLOAD_SUCCESS:
|
return state.set('is_submitting', false);
|
||||||
return appendMedia(state, Immutable.fromJS(action.media));
|
case COMPOSE_UPLOAD_REQUEST:
|
||||||
case COMPOSE_UPLOAD_FAIL:
|
return state.withMutations(map => {
|
||||||
return state.set('is_uploading', false);
|
map.set('is_uploading', true);
|
||||||
case COMPOSE_UPLOAD_UNDO:
|
map.set('fileDropDate', new Date());
|
||||||
return removeMedia(state, action.media_id);
|
});
|
||||||
case COMPOSE_UPLOAD_PROGRESS:
|
case COMPOSE_UPLOAD_SUCCESS:
|
||||||
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
return appendMedia(state, Immutable.fromJS(action.media));
|
||||||
case COMPOSE_MENTION:
|
case COMPOSE_UPLOAD_FAIL:
|
||||||
return state.update('text', text => `${text}@${action.account.get('acct')} `);
|
return state.set('is_uploading', false);
|
||||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
case COMPOSE_UPLOAD_UNDO:
|
||||||
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
|
return removeMedia(state, action.media_id);
|
||||||
case COMPOSE_SUGGESTIONS_READY:
|
case COMPOSE_UPLOAD_PROGRESS:
|
||||||
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
|
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
||||||
case COMPOSE_SUGGESTION_SELECT:
|
case COMPOSE_MENTION:
|
||||||
return insertSuggestion(state, action.position, action.token, action.completion);
|
return state.update('text', text => `${text}@${action.account.get('acct')} `);
|
||||||
case TIMELINE_DELETE:
|
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||||
if (action.id === state.get('in_reply_to')) {
|
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
|
||||||
return state.set('in_reply_to', null);
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
} else {
|
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
|
||||||
return state;
|
case COMPOSE_SUGGESTION_SELECT:
|
||||||
}
|
return insertSuggestion(state, action.position, action.token, action.completion);
|
||||||
case ACCOUNT_SET_SELF:
|
case TIMELINE_DELETE:
|
||||||
return state.set('me', action.account.id).set('private', action.account.locked);
|
if (action.id === state.get('in_reply_to')) {
|
||||||
default:
|
return state.set('in_reply_to', null);
|
||||||
|
} else {
|
||||||
return state;
|
return state;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,9 @@ import statuses from './statuses';
|
|||||||
import relationships from './relationships';
|
import relationships from './relationships';
|
||||||
import search from './search';
|
import search from './search';
|
||||||
import notifications from './notifications';
|
import notifications from './notifications';
|
||||||
|
import settings from './settings';
|
||||||
|
import status_lists from './status_lists';
|
||||||
|
import cards from './cards';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
timelines,
|
timelines,
|
||||||
@ -20,9 +23,12 @@ export default combineReducers({
|
|||||||
loadingBar: loadingBarReducer,
|
loadingBar: loadingBarReducer,
|
||||||
modal,
|
modal,
|
||||||
user_lists,
|
user_lists,
|
||||||
|
status_lists,
|
||||||
accounts,
|
accounts,
|
||||||
statuses,
|
statuses,
|
||||||
relationships,
|
relationships,
|
||||||
search,
|
search,
|
||||||
notifications
|
notifications,
|
||||||
|
settings,
|
||||||
|
cards
|
||||||
});
|
});
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import { ACCESS_TOKEN_SET } from '../actions/meta';
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map();
|
const initialState = Immutable.Map({
|
||||||
|
access_token: null,
|
||||||
|
me: null
|
||||||
|
});
|
||||||
|
|
||||||
export default function meta(state = initialState, action) {
|
export default function meta(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ACCESS_TOKEN_SET:
|
case STORE_HYDRATE:
|
||||||
return state.set('access_token', action.token);
|
return state.merge(action.state.get('meta'));
|
||||||
case ACCOUNT_SET_SELF:
|
default:
|
||||||
return state.set('me', action.account.id);
|
return state;
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -8,14 +8,14 @@ const initialState = Immutable.Map({
|
|||||||
|
|
||||||
export default function modal(state = initialState, action) {
|
export default function modal(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case MEDIA_OPEN:
|
case MEDIA_OPEN:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('url', action.url);
|
map.set('url', action.url);
|
||||||
map.set('open', true);
|
map.set('open', true);
|
||||||
});
|
});
|
||||||
case MODAL_CLOSE:
|
case MODAL_CLOSE:
|
||||||
return state.set('open', false);
|
return state.set('open', false);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,10 @@ import {
|
|||||||
NOTIFICATIONS_UPDATE,
|
NOTIFICATIONS_UPDATE,
|
||||||
NOTIFICATIONS_REFRESH_SUCCESS,
|
NOTIFICATIONS_REFRESH_SUCCESS,
|
||||||
NOTIFICATIONS_EXPAND_SUCCESS,
|
NOTIFICATIONS_EXPAND_SUCCESS,
|
||||||
NOTIFICATIONS_SETTING_CHANGE
|
NOTIFICATIONS_REFRESH_REQUEST,
|
||||||
|
NOTIFICATIONS_EXPAND_REQUEST,
|
||||||
|
NOTIFICATIONS_REFRESH_FAIL,
|
||||||
|
NOTIFICATIONS_EXPAND_FAIL
|
||||||
} from '../actions/notifications';
|
} from '../actions/notifications';
|
||||||
import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
|
import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
@ -11,22 +14,7 @@ const initialState = Immutable.Map({
|
|||||||
items: Immutable.List(),
|
items: Immutable.List(),
|
||||||
next: null,
|
next: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
isLoading: true
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const notificationToMap = notification => Immutable.Map({
|
const notificationToMap = notification => Immutable.Map({
|
||||||
@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => {
|
|||||||
items = items.set(i, notificationToMap(n));
|
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) => {
|
const appendNormalizedNotifications = (state, notifications, next) => {
|
||||||
@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => {
|
|||||||
items = items.set(i, notificationToMap(n));
|
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) => {
|
const filterNotifications = (state, relationship) => {
|
||||||
@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => {
|
|||||||
|
|
||||||
export default function notifications(state = initialState, action) {
|
export default function notifications(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case NOTIFICATIONS_UPDATE:
|
case NOTIFICATIONS_REFRESH_REQUEST:
|
||||||
return normalizeNotification(state, action.notification);
|
case NOTIFICATIONS_EXPAND_REQUEST:
|
||||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
case NOTIFICATIONS_REFRESH_FAIL:
|
||||||
return normalizeNotifications(state, action.notifications, action.next);
|
case NOTIFICATIONS_EXPAND_FAIL:
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
return state.set('isLoading', true);
|
||||||
return appendNormalizedNotifications(state, action.notifications, action.next);
|
case NOTIFICATIONS_UPDATE:
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
return normalizeNotification(state, action.notification);
|
||||||
return filterNotifications(state, action.relationship);
|
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||||
case NOTIFICATIONS_SETTING_CHANGE:
|
return normalizeNotifications(state, action.notifications, action.next);
|
||||||
return state.setIn(['settings', ...action.key], action.checked);
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
default:
|
return appendNormalizedNotifications(state, action.notifications, action.next);
|
||||||
return state;
|
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({
|
newSuggestions.push({
|
||||||
title: 'hashtag',
|
title: 'hashtag',
|
||||||
items: [
|
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_REFRESH_SUCCESS,
|
||||||
NOTIFICATIONS_EXPAND_SUCCESS
|
NOTIFICATIONS_EXPAND_SUCCESS
|
||||||
} from '../actions/notifications';
|
} from '../actions/notifications';
|
||||||
|
import {
|
||||||
|
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||||
|
FAVOURITED_STATUSES_EXPAND_SUCCESS
|
||||||
|
} from '../actions/favourites';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const normalizeStatus = (state, status) => {
|
const normalizeStatus = (state, status) => {
|
||||||
@ -77,36 +81,38 @@ const initialState = Immutable.Map();
|
|||||||
|
|
||||||
export default function statuses(state = initialState, action) {
|
export default function statuses(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case TIMELINE_UPDATE:
|
case TIMELINE_UPDATE:
|
||||||
case STATUS_FETCH_SUCCESS:
|
case STATUS_FETCH_SUCCESS:
|
||||||
case NOTIFICATIONS_UPDATE:
|
case NOTIFICATIONS_UPDATE:
|
||||||
return normalizeStatus(state, action.status);
|
return normalizeStatus(state, action.status);
|
||||||
case REBLOG_SUCCESS:
|
case REBLOG_SUCCESS:
|
||||||
case UNREBLOG_SUCCESS:
|
case UNREBLOG_SUCCESS:
|
||||||
case FAVOURITE_SUCCESS:
|
case FAVOURITE_SUCCESS:
|
||||||
case UNFAVOURITE_SUCCESS:
|
case UNFAVOURITE_SUCCESS:
|
||||||
return normalizeStatus(state, action.response);
|
return normalizeStatus(state, action.response);
|
||||||
case FAVOURITE_REQUEST:
|
case FAVOURITE_REQUEST:
|
||||||
return state.setIn([action.status.get('id'), 'favourited'], true);
|
return state.setIn([action.status.get('id'), 'favourited'], true);
|
||||||
case FAVOURITE_FAIL:
|
case FAVOURITE_FAIL:
|
||||||
return state.setIn([action.status.get('id'), 'favourited'], false);
|
return state.setIn([action.status.get('id'), 'favourited'], false);
|
||||||
case REBLOG_REQUEST:
|
case REBLOG_REQUEST:
|
||||||
return state.setIn([action.status.get('id'), 'reblogged'], true);
|
return state.setIn([action.status.get('id'), 'reblogged'], true);
|
||||||
case REBLOG_FAIL:
|
case REBLOG_FAIL:
|
||||||
return state.setIn([action.status.get('id'), 'reblogged'], false);
|
return state.setIn([action.status.get('id'), 'reblogged'], false);
|
||||||
case TIMELINE_REFRESH_SUCCESS:
|
case TIMELINE_REFRESH_SUCCESS:
|
||||||
case TIMELINE_EXPAND_SUCCESS:
|
case TIMELINE_EXPAND_SUCCESS:
|
||||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||||
case CONTEXT_FETCH_SUCCESS:
|
case CONTEXT_FETCH_SUCCESS:
|
||||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
return normalizeStatuses(state, action.statuses);
|
case FAVOURITED_STATUSES_FETCH_SUCCESS:
|
||||||
case TIMELINE_DELETE:
|
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
|
||||||
return deleteStatus(state, action.id, action.references);
|
return normalizeStatuses(state, action.statuses);
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case TIMELINE_DELETE:
|
||||||
return filterStatuses(state, action.relationship);
|
return deleteStatus(state, action.id, action.references);
|
||||||
default:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
return state;
|
return filterStatuses(state, action.relationship);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
TIMELINE_REFRESH_REQUEST,
|
TIMELINE_REFRESH_REQUEST,
|
||||||
TIMELINE_REFRESH_SUCCESS,
|
TIMELINE_REFRESH_SUCCESS,
|
||||||
|
TIMELINE_REFRESH_FAIL,
|
||||||
TIMELINE_UPDATE,
|
TIMELINE_UPDATE,
|
||||||
TIMELINE_DELETE,
|
TIMELINE_DELETE,
|
||||||
TIMELINE_EXPAND_SUCCESS,
|
TIMELINE_EXPAND_SUCCESS,
|
||||||
|
TIMELINE_EXPAND_REQUEST,
|
||||||
|
TIMELINE_EXPAND_FAIL,
|
||||||
TIMELINE_SCROLL_TOP
|
TIMELINE_SCROLL_TOP
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import {
|
import {
|
||||||
@ -13,37 +16,43 @@ import {
|
|||||||
UNFAVOURITE_SUCCESS
|
UNFAVOURITE_SUCCESS
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_FETCH_SUCCESS,
|
ACCOUNT_TIMELINE_FETCH_REQUEST,
|
||||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||||
|
ACCOUNT_TIMELINE_FETCH_FAIL,
|
||||||
|
ACCOUNT_TIMELINE_EXPAND_REQUEST,
|
||||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
||||||
|
ACCOUNT_TIMELINE_EXPAND_FAIL,
|
||||||
ACCOUNT_BLOCK_SUCCESS
|
ACCOUNT_BLOCK_SUCCESS
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import {
|
import {
|
||||||
STATUS_FETCH_SUCCESS,
|
|
||||||
CONTEXT_FETCH_SUCCESS
|
CONTEXT_FETCH_SUCCESS
|
||||||
} from '../actions/statuses';
|
} from '../actions/statuses';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = Immutable.Map({
|
||||||
home: Immutable.Map({
|
home: Immutable.Map({
|
||||||
|
isLoading: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
items: Immutable.List()
|
items: Immutable.List()
|
||||||
}),
|
}),
|
||||||
|
|
||||||
mentions: Immutable.Map({
|
mentions: Immutable.Map({
|
||||||
|
isLoading: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
items: Immutable.List()
|
items: Immutable.List()
|
||||||
}),
|
}),
|
||||||
|
|
||||||
public: Immutable.Map({
|
public: Immutable.Map({
|
||||||
|
isLoading: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
items: Immutable.List()
|
items: Immutable.List()
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tag: Immutable.Map({
|
tag: Immutable.Map({
|
||||||
|
isLoading: false,
|
||||||
id: null,
|
id: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = state.setIn([timeline, 'loaded'], true);
|
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));
|
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'));
|
moreIds = moreIds.set(i, status.get('id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
state = state.setIn([timeline, 'isLoading'], false);
|
||||||
|
|
||||||
return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
|
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'));
|
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) => {
|
const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
|
||||||
@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
|
|||||||
moreIds = moreIds.set(i, status.get('id'));
|
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) => {
|
const updateTimeline = (state, timeline, status, references) => {
|
||||||
@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => {
|
|||||||
return state;
|
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
|
// Remove references from timelines
|
||||||
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
|
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
|
||||||
state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
|
state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove references from account timelines
|
// 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
|
// Remove references from context
|
||||||
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
|
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) {
|
if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
|
||||||
state = state.update(timeline, map => map
|
state = state.update(timeline, map => map
|
||||||
.set('id', id)
|
.set('id', id)
|
||||||
|
.set('isLoading', true)
|
||||||
.set('loaded', false)
|
.set('loaded', false)
|
||||||
.update('items', list => list.clear()));
|
.update('items', list => list.clear()));
|
||||||
|
} else {
|
||||||
|
state = state.setIn([timeline, 'isLoading'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => {
|
|||||||
|
|
||||||
export default function timelines(state = initialState, action) {
|
export default function timelines(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case TIMELINE_REFRESH_REQUEST:
|
case TIMELINE_REFRESH_REQUEST:
|
||||||
return resetTimeline(state, action.timeline, action.id);
|
case TIMELINE_EXPAND_REQUEST:
|
||||||
case TIMELINE_REFRESH_SUCCESS:
|
return resetTimeline(state, action.timeline, action.id);
|
||||||
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
case TIMELINE_REFRESH_FAIL:
|
||||||
case TIMELINE_EXPAND_SUCCESS:
|
case TIMELINE_EXPAND_FAIL:
|
||||||
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
return state.setIn([action.timeline, 'isLoading'], false);
|
||||||
case TIMELINE_UPDATE:
|
case TIMELINE_REFRESH_SUCCESS:
|
||||||
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
|
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_EXPAND_SUCCESS:
|
||||||
return deleteStatus(state, action.id, action.accountId, action.references);
|
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
||||||
case CONTEXT_FETCH_SUCCESS:
|
case TIMELINE_UPDATE:
|
||||||
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
|
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
|
||||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
case TIMELINE_DELETE:
|
||||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
|
return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
|
||||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
case CONTEXT_FETCH_SUCCESS:
|
||||||
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_TIMELINE_FETCH_REQUEST:
|
||||||
return filterTimelines(state, action.relationship, action.statuses);
|
case ACCOUNT_TIMELINE_EXPAND_REQUEST:
|
||||||
case TIMELINE_SCROLL_TOP:
|
return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
|
||||||
return state.setIn([action.timeline, 'top'], action.top);
|
case ACCOUNT_TIMELINE_FETCH_FAIL:
|
||||||
default:
|
case ACCOUNT_TIMELINE_EXPAND_FAIL:
|
||||||
return state;
|
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…
Reference in New Issue
Block a user