Browse Source

Merge pull request #3 from tootsuite/master

Updating to current
closed-social-glitch-2
Anthony Bellew 7 years ago
committed by GitHub
parent
commit
3d890c4073
276 changed files with 4847 additions and 1342 deletions
  1. +1
    -0
      .env.vagrant
  2. +3
    -0
      .gitignore
  3. +1
    -0
      .rubocop.yml
  4. +7
    -3
      Gemfile
  5. +23
    -11
      Gemfile.lock
  6. +2
    -0
      Procfile
  7. +21
    -9
      README.md
  8. +109
    -0
      Vagrantfile
  9. +91
    -0
      app.json
  10. BIN
     
  11. BIN
     
  12. +5
    -0
      app/assets/javascripts/application_public.js
  13. +34
    -26
      app/assets/javascripts/components/actions/accounts.jsx
  14. +47
    -0
      app/assets/javascripts/components/actions/cards.jsx
  15. +17
    -0
      app/assets/javascripts/components/actions/compose.jsx
  16. +83
    -0
      app/assets/javascripts/components/actions/favourites.jsx
  17. +0
    -8
      app/assets/javascripts/components/actions/meta.jsx
  18. +13
    -15
      app/assets/javascripts/components/actions/notifications.jsx
  19. +19
    -0
      app/assets/javascripts/components/actions/settings.jsx
  20. +17
    -11
      app/assets/javascripts/components/actions/statuses.jsx
  21. +17
    -0
      app/assets/javascripts/components/actions/store.jsx
  22. +37
    -15
      app/assets/javascripts/components/actions/timelines.jsx
  23. +21
    -5
      app/assets/javascripts/components/components/account.jsx
  24. +16
    -2
      app/assets/javascripts/components/components/autosuggest_textarea.jsx
  25. +31
    -2
      app/assets/javascripts/components/components/avatar.jsx
  26. +1
    -1
      app/assets/javascripts/components/components/button.jsx
  27. +60
    -0
      app/assets/javascripts/components/components/column_collapsable.jsx
  28. +4
    -2
      app/assets/javascripts/components/components/dropdown_menu.jsx
  29. +17
    -5
      app/assets/javascripts/components/components/icon_button.jsx
  30. +11
    -11
      app/assets/javascripts/components/components/lightbox.jsx
  31. +12
    -10
      app/assets/javascripts/components/components/loading_indicator.jsx
  32. +39
    -14
      app/assets/javascripts/components/components/media_gallery.jsx
  33. +17
    -0
      app/assets/javascripts/components/components/missing_indicator.jsx
  34. +12
    -9
      app/assets/javascripts/components/components/relative_timestamp.jsx
  35. +3
    -3
      app/assets/javascripts/components/components/status_action_bar.jsx
  36. +55
    -7
      app/assets/javascripts/components/components/status_content.jsx
  37. +27
    -10
      app/assets/javascripts/components/components/status_list.jsx
  38. +52
    -14
      app/assets/javascripts/components/components/video_player.jsx
  39. +11
    -1
      app/assets/javascripts/components/containers/account_container.jsx
  40. +20
    -19
      app/assets/javascripts/components/containers/mastodon.jsx
  41. +5
    -1
      app/assets/javascripts/components/containers/status_container.jsx
  42. +1
    -1
      app/assets/javascripts/components/emoji.jsx
  43. +1
    -1
      app/assets/javascripts/components/features/account/components/action_bar.jsx
  44. +2
    -2
      app/assets/javascripts/components/features/account/components/header.jsx
  45. +10
    -1
      app/assets/javascripts/components/features/account/index.jsx
  46. +7
    -4
      app/assets/javascripts/components/features/account_timeline/index.jsx
  47. +42
    -5
      app/assets/javascripts/components/features/compose/components/compose_form.jsx
  48. +61
    -12
      app/assets/javascripts/components/features/compose/components/drawer.jsx
  49. +2
    -2
      app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
  50. +1
    -1
      app/assets/javascripts/components/features/compose/components/search.jsx
  51. +6
    -4
      app/assets/javascripts/components/features/compose/components/upload_button.jsx
  52. +9
    -4
      app/assets/javascripts/components/features/compose/components/upload_form.jsx
  53. +14
    -1
      app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
  54. +5
    -3
      app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
  55. +1
    -0
      app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
  56. +3
    -2
      app/assets/javascripts/components/features/compose/index.jsx
  57. +63
    -0
      app/assets/javascripts/components/features/favourited_statuses/index.jsx
  58. +10
    -0
      app/assets/javascripts/components/features/generic_not_found/index.jsx
  59. +14
    -21
      app/assets/javascripts/components/features/getting_started/index.jsx
  60. +68
    -0
      app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
  61. +41
    -0
      app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx
  62. +21
    -0
      app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx
  63. +4
    -8
      app/assets/javascripts/components/features/home_timeline/index.jsx
  64. +41
    -109
      app/assets/javascripts/components/features/notifications/components/column_settings.jsx
  65. +5
    -2
      app/assets/javascripts/components/features/notifications/components/notification.jsx
  66. +32
    -0
      app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
  67. +7
    -3
      app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
  68. +9
    -13
      app/assets/javascripts/components/features/notifications/index.jsx
  69. +2
    -2
      app/assets/javascripts/components/features/status/components/action_bar.jsx
  70. +100
    -0
      app/assets/javascripts/components/features/status/components/card.jsx
  71. +11
    -2
      app/assets/javascripts/components/features/status/components/detailed_status.jsx
  72. +8
    -0
      app/assets/javascripts/components/features/status/containers/card_container.jsx
  73. +7
    -1
      app/assets/javascripts/components/features/status/index.jsx
  74. +2
    -2
      app/assets/javascripts/components/features/ui/components/column_link.jsx
  75. +3
    -4
      app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
  76. +25
    -4
      app/assets/javascripts/components/features/ui/containers/modal_container.jsx
  77. +45
    -15
      app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
  78. +15
    -6
      app/assets/javascripts/components/features/ui/index.jsx
  79. +5
    -0
      app/assets/javascripts/components/is_mobile.jsx
  80. +25
    -2
      app/assets/javascripts/components/locales/de.jsx
  81. +4
    -2
      app/assets/javascripts/components/locales/en.jsx
  82. +2
    -1
      app/assets/javascripts/components/locales/es.jsx
  83. +2
    -1
      app/assets/javascripts/components/locales/fr.jsx
  84. +2
    -1
      app/assets/javascripts/components/locales/hu.jsx
  85. +2
    -1
      app/assets/javascripts/components/locales/pt.jsx
  86. +2
    -1
      app/assets/javascripts/components/locales/uk.jsx
  87. +1
    -1
      app/assets/javascripts/components/middleware/errors.jsx
  88. +25
    -0
      app/assets/javascripts/components/middleware/loading_bar.jsx
  89. +48
    -35
      app/assets/javascripts/components/reducers/accounts.jsx
  90. +14
    -0
      app/assets/javascripts/components/reducers/cards.jsx
  91. +73
    -61
      app/assets/javascripts/components/reducers/compose.jsx
  92. +7
    -1
      app/assets/javascripts/components/reducers/index.jsx
  93. +9
    -9
      app/assets/javascripts/components/reducers/meta.jsx
  94. +9
    -9
      app/assets/javascripts/components/reducers/modal.jsx
  95. +29
    -31
      app/assets/javascripts/components/reducers/notifications.jsx
  96. +1
    -1
      app/assets/javascripts/components/reducers/search.jsx
  97. +46
    -0
      app/assets/javascripts/components/reducers/settings.jsx
  98. +39
    -0
      app/assets/javascripts/components/reducers/status_lists.jsx
  99. +37
    -31
      app/assets/javascripts/components/reducers/statuses.jsx
  100. +63
    -28
      app/assets/javascripts/components/reducers/timelines.jsx

+ 1
- 0
.env.vagrant View File

@ -0,0 +1 @@
VAGRANT=true

+ 3
- 0
.gitignore View File

@ -22,3 +22,6 @@ public/assets
.env.production
node_modules/
neo4j/
# Ignore Vagrant files
.vagrant/

+ 1
- 0
.rubocop.yml View File

@ -87,3 +87,4 @@ AllCops:
- 'bin/*'
- 'Rakefile'
- 'node_modules/**/*'
- 'Vagrantfile'

+ 7
- 3
Gemfile View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '2.3.1'
gem 'rails', '~> 5.0.1.0'
gem 'sass-rails', '~> 5.0'
@ -16,8 +17,9 @@ gem 'pg'
gem 'pghero'
gem 'dotenv-rails'
gem 'font-awesome-rails'
gem 'best_in_place', '~> 3.0.1'
gem 'paperclip', '~> 5.0'
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder'
gem 'aws-sdk', '>= 2.0'
@ -29,7 +31,6 @@ gem 'link_header'
gem 'ostatus2'
gem 'goldfinger'
gem 'devise'
gem 'rails_autolink'
gem 'doorkeeper'
gem 'rabl'
gem 'oj'
@ -42,9 +43,11 @@ gem 'will_paginate'
gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'
gem 'sidekiq'
gem 'ledermann-rails-settings'
gem 'rails-settings-cached'
gem 'pg_search'
gem 'simple-navigation'
gem 'statsd-instrument'
gem 'ruby-oembed', require: 'oembed'
gem 'react-rails'
gem 'browserify-rails'
@ -69,6 +72,7 @@ group :development do
gem 'better_errors'
gem 'binding_of_caller'
gem 'letter_opener'
gem 'letter_opener_web'
gem 'bullet'
gem 'active_record_query_trace'
end

+ 23
- 11
Gemfile.lock View File

@ -60,6 +60,9 @@ GEM
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.11)
best_in_place (3.0.3)
actionpack (>= 3.2)
railties (>= 3.2)
better_errors (2.1.1)
coderay (>= 1.0.0)
erubis (>= 2.6.6)
@ -73,8 +76,7 @@ GEM
bullet (5.3.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
climate_control (0.0.3)
activesupport (>= 3.0)
climate_control (0.1.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.1)
@ -86,7 +88,7 @@ GEM
execjs
coffee-script-source (1.10.0)
colorize (0.8.1)
concurrent-ruby (1.0.3)
concurrent-ruby (1.0.4)
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
@ -172,10 +174,12 @@ GEM
json (1.8.3)
launchy (2.4.3)
addressable (~> 2.3)
ledermann-rails-settings (2.4.2)
activerecord (>= 3.1)
letter_opener (1.4.1)
launchy (~> 2.2)
letter_opener_web (1.3.0)
actionmailer (>= 3.2)
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.4.1)
actionpack (>= 4, < 5.1)
@ -259,11 +263,11 @@ GEM
nokogiri (~> 1.6.0)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
rails-settings-cached (0.6.5)
rails (>= 4.2.0)
rails_12factor (0.0.3)
rails_serve_static_assets
rails_stdout_logging
rails_autolink (1.1.6)
rails (> 3.1)
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.0.1)
@ -332,6 +336,7 @@ GEM
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.10.1)
ruby-progressbar (1.8.1)
safe_yaml (1.0.4)
sass (3.4.22)
@ -367,6 +372,7 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
statsd-instrument (2.1.2)
temple (0.7.7)
term-ansicolor (1.4.0)
tins (~> 1.0)
@ -405,6 +411,7 @@ DEPENDENCIES
addressable
autoprefixer-rails
aws-sdk (>= 2.0)
best_in_place (~> 3.0.1)
better_errors
binding_of_caller
browserify-rails
@ -426,14 +433,14 @@ DEPENDENCIES
i18n-tasks (~> 0.9.6)
jbuilder (~> 2.0)
jquery-rails
ledermann-rails-settings
letter_opener
letter_opener_web
link_header
lograge
nokogiri
oj
ostatus2
paperclip (~> 5.0)
paperclip (~> 5.1)
paperclip-av-transcoder
pg
pg_search
@ -445,23 +452,28 @@ DEPENDENCIES
rack-cors
rack-timeout-puma
rails (~> 5.0.1.0)
rails-settings-cached
rails_12factor
rails_autolink
react-rails
redis (~> 3.2)
redis-rails
rspec-rails
rspec-sidekiq
rubocop
ruby-oembed
sass-rails (~> 5.0)
sdoc (~> 0.4.0)
sidekiq
simple-navigation
simple_form
simplecov
statsd-instrument
uglifier (>= 1.3.0)
webmock
will_paginate
RUBY VERSION
ruby 2.3.1p112
BUNDLED WITH
1.13.6
1.13.7

+ 2
- 0
Procfile View File

@ -0,0 +1,2 @@
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -q default -q mailers -q push

+ 21
- 9
README.md View File

@ -1,11 +1,11 @@
Mastodon
========
[![Build Status](http://img.shields.io/travis/Gargron/goldfinger.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/Gargron/mastodon.svg)][code_climate]
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
[travis]: https://travis-ci.org/Gargron/mastodon
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
[travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
@ -25,11 +25,11 @@ If you would like, you can [support the development of this project on Patreon][
## Resources
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
- [List of Mastodon instances](docs/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
- [API overview](docs/Using-the-API/API.md)
- [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md)
- [List of apps](docs/Using-Mastodon/Apps.md)
## Features
@ -115,7 +115,19 @@ Which will re-create the updated containers, leaving databases and data as is. D
## Deployment without Docker
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/Gargron/mastodon/wiki/Production-guide) for examples, configuration and instructions.
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
## Deployment on Heroku (experimental)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku.md)
## Development with Vagrant
A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
[You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant.md)
## Contributing

+ 109
- 0
Vagrantfile View 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
- 0
app.json View 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"
]
}

BIN
View File


BIN
View File


+ 5
- 0
app/assets/javascripts/application_public.js View File

@ -1,3 +1,8 @@
//= require jquery
//= require jquery_ujs
//= require extras
//= require best_in_place
$(function () {
$(".best_in_place").best_in_place();
});

+ 34
- 26
app/assets/javascripts/components/actions/accounts.jsx View File

@ -1,8 +1,6 @@
import api, { getLinks } from '../api'
import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
@ -67,13 +65,6 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export function setAccountSelf(account) {
return {
type: ACCOUNT_SET_SELF,
account
};
};
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchAccountRequest(id));
@ -89,32 +80,39 @@ export function fetchAccount(id) {
export function fetchAccountTimeline(id, replace = false) {
return (dispatch, getState) => {
dispatch(fetchAccountTimelineRequest(id));
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let skipLoading = false;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
params = `?since_id=${newestId}`;
skipLoading = true;
}
dispatch(fetchAccountTimelineRequest(id, skipLoading));
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
}).catch(error => {
dispatch(fetchAccountTimelineFail(id, error));
dispatch(fetchAccountTimelineFail(id, error, skipLoading));
});
};
};
export function expandAccountTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
dispatch(expandAccountTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => {
api(getState).get(`/api/v1/accounts/${id}/statuses`, {
params: {
limit: 10,
max_id: lastId
}
}).then(response => {
dispatch(expandAccountTimelineSuccess(id, response.data));
}).catch(error => {
dispatch(expandAccountTimelineFail(id, error));
@ -210,27 +208,30 @@ export function unfollowAccountFail(error) {
};
};
export function fetchAccountTimelineRequest(id) {
export function fetchAccountTimelineRequest(id, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
id
id,
skipLoading
};
};
export function fetchAccountTimelineSuccess(id, statuses, replace) {
export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id,
statuses,
replace
replace,
skipLoading
};
};
export function fetchAccountTimelineFail(id, error) {
export function fetchAccountTimelineFail(id, error, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_FAIL,
id,
error
error,
skipLoading
};
};
@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) {
export function fetchRelationships(account_ids) {
return (dispatch, getState) => {
if (account_ids.length === 0) {
return;
}
dispatch(fetchRelationshipsRequest(account_ids));
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
@ -508,21 +513,24 @@ export function fetchRelationships(account_ids) {
export function fetchRelationshipsRequest(ids) {
return {
type: RELATIONSHIPS_FETCH_REQUEST,
ids
ids,
skipLoading: true
};
};
export function fetchRelationshipsSuccess(relationships) {
return {
type: RELATIONSHIPS_FETCH_SUCCESS,
relationships
relationships,
skipLoading: true
};
};
export function fetchRelationshipsFail(error) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
error
error,
skipLoading: true
};
};

+ 47
- 0
app/assets/javascripts/components/actions/cards.jsx View 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
};
};

+ 17
- 0
app/assets/javascripts/components/actions/compose.jsx View File

@ -23,6 +23,8 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
@ -68,6 +70,7 @@ export function submitCompose() {
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
}).then(function (response) {
dispatch(submitComposeSuccess({ ...response.data }));
@ -218,6 +221,20 @@ export function changeComposeSensitivity(checked) {
};
};
export function changeComposeSpoilerness(checked) {
return {
type: COMPOSE_SPOILERNESS_CHANGE,
checked
};
};
export function changeComposeSpoilerText(text) {
return {
type: COMPOSE_SPOILER_TEXT_CHANGE,
text
};
};
export function changeComposeVisibility(checked) {
return {
type: COMPOSE_VISIBILITY_CHANGE,

+ 83
- 0
app/assets/javascripts/components/actions/favourites.jsx View 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
};
};

+ 0
- 8
app/assets/javascripts/components/actions/meta.jsx View File

@ -1,8 +0,0 @@
export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
export function setAccessToken(token) {
return {
type: ACCESS_TOKEN_SET,
token: token
};
};

+ 13
- 15
app/assets/javascripts/components/actions/notifications.jsx View File

@ -14,8 +14,6 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE';
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
@ -26,21 +24,25 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
dispatch({
type: NOTIFICATIONS_UPDATE,
notification,
account: notification.account,
status: notification.status
status: notification.status,
meta: playSound ? { sound: 'boop' } : undefined
});
fetchRelatedRelationships(dispatch, [notification]);
// Desktop notifications
if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) {
if (typeof window.Notification !== 'undefined' && showAlert) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = $('<p>').html(notification.status ? notification.status.content : '').text();
new Notification(title, { body, icon: notification.account.avatar });
new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
}
};
};
@ -94,13 +96,17 @@ export function expandNotifications() {
return (dispatch, getState) => {
const url = getState().getIn(['notifications', 'next'], null);
if (url === null) {
if (url === null || getState().getIn(['notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsRequest());
api(getState).get(url).then(response => {
api(getState).get(url, {
params: {
limit: 5
}
}).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
@ -133,11 +139,3 @@ export function expandNotificationsFail(error) {
error
};
};
export function changeNotificationsSetting(key, checked) {
return {
type: NOTIFICATIONS_SETTING_CHANGE,
key,
checked
};
};

+ 19
- 0
app/assets/javascripts/components/actions/settings.jsx View 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()
});
};
};

+ 17
- 11
app/assets/javascripts/components/actions/statuses.jsx View File

@ -1,6 +1,7 @@
import api from '../api';
import { deleteFromTimelines } from './timelines';
import { fetchStatusCard } from './cards';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -14,39 +15,44 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
export function fetchStatusRequest(id) {
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
id: id
id,
skipLoading
};
};
export function fetchStatus(id) {
return (dispatch, getState) => {
dispatch(fetchStatusRequest(id));
const skipLoading = getState().getIn(['statuses', id], null) !== null;
dispatch(fetchStatusRequest(id, skipLoading));
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
dispatch(fetchStatusSuccess(response.data));
dispatch(fetchStatusSuccess(response.data, skipLoading));
dispatch(fetchContext(id));
dispatch(fetchStatusCard(id));
}).catch(error => {
dispatch(fetchStatusFail(id, error));
dispatch(fetchStatusFail(id, error, skipLoading));
});
};
};
export function fetchStatusSuccess(status, context) {
export function fetchStatusSuccess(status, skipLoading) {
return {
type: STATUS_FETCH_SUCCESS,
status: status,
context: context
status,
skipLoading
};
};
export function fetchStatusFail(id, error) {
export function fetchStatusFail(id, error, skipLoading) {
return {
type: STATUS_FETCH_FAIL,
id: id,
error: error
id,
error,
skipLoading
};
};

+ 17
- 0
app/assets/javascripts/components/actions/store.jsx View 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
};
};

+ 37
- 15
app/assets/javascripts/components/actions/timelines.jsx View File

@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export function refreshTimelineSuccess(timeline, statuses) {
export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
return {
type: TIMELINE_REFRESH_SUCCESS,
timeline: timeline,
statuses: statuses
timeline,
statuses,
skipLoading
};
};
@ -39,55 +40,65 @@ export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
type: TIMELINE_DELETE,
id,
accountId,
references
references,
reblogOf
});
};
};
export function refreshTimelineRequest(timeline, id) {
export function refreshTimelineRequest(timeline, id, skipLoading) {
return {
type: TIMELINE_REFRESH_REQUEST,
timeline,
id
id,
skipLoading
};
};
export function refreshTimeline(timeline, id = null) {
return function (dispatch, getState) {
dispatch(refreshTimelineRequest(timeline, id));
if (getState().getIn(['timelines', timeline, 'isLoading'])) {
return;
}
const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let path = timeline;
let params = '';
let path = timeline;
let skipLoading = false;
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
params = `?since_id=${newestId}`;
params = `?since_id=${newestId}`;
skipLoading = true;
}
if (id) {
path = `${path}/${id}`
}
dispatch(refreshTimelineRequest(timeline, id, skipLoading));
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data));
dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
}).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error));
dispatch(refreshTimelineFail(timeline, error, skipLoading));
});
};
};
export function refreshTimelineFail(timeline, error) {
export function refreshTimelineFail(timeline, error, skipLoading) {
return {
type: TIMELINE_REFRESH_FAIL,
timeline,
error
error,
skipLoading
};
};
@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) {
// If timeline is empty, don't try to load older posts since there are none
// Also if already loading
return;
}
dispatch(expandTimelineRequest(timeline));
let path = timeline;
@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) {
path = `${path}/${id}`
}
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
api(getState).get(`/api/v1/timelines/${path}`, {
params: {
limit: 10,
max_id: lastId
}
}).then(response => {
dispatch(expandTimelineSuccess(timeline, response.data));
}).catch(error => {
dispatch(expandTimelineFail(timeline, error));

+ 21
- 5
app/assets/javascripts/components/components/account.jsx View File

@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
});
const outerStyle = {
@ -42,7 +44,9 @@ const Account = React.createClass({
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool
onBlock: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
},
getDefaultProps () {
@ -57,6 +61,10 @@ const Account = React.createClass({
this.props.onFollow(this.props.account);
},
handleBlock () {
this.props.onBlock(this.props.account);
},
render () {
const { account, me, withNote, intl } = this.props;
@ -70,10 +78,18 @@ const Account = React.createClass({
note = <div style={noteStyle}>{account.get('note')}</div>;
}
if (account.get('id') !== me && account.get('relationship', null) != null) {
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
if (requested) {
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
} else if (blocking) {
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}
}
return (

+ 16
- 2
app/assets/javascripts/components/components/autosuggest_textarea.jsx View File

@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
onChange: React.PropTypes.func.isRequired,
onKeyUp: React.PropTypes.func
onKeyUp: React.PropTypes.func,
onKeyDown: React.PropTypes.func
},
getInitialState () {
@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
},
onBlur () {
this.setState({ suggestionsHidden: true });
// If we hide the suggestions immediately, then this will prevent the
// onClick for the suggestions themselves from firing.
// Setting a short window for that to take place before hiding the
// suggestions ensures that can't happen.
setTimeout(() => {
this.setState({ suggestionsHidden: true });
}, 100);
},
onSuggestionClick (suggestion, e) {
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
},
componentWillReceiveProps (nextProps) {

+ 31
- 2
app/assets/javascripts/components/components/avatar.jsx View File

@ -8,12 +8,41 @@ const Avatar = React.createClass({
style: React.PropTypes.object
},
getInitialState () {
return {
hovering: false
};
},
mixins: [PureRenderMixin],
handleMouseEnter () {
this.setState({ hovering: true });
},
handleMouseLeave () {
this.setState({ hovering: false });
},
handleLoad () {
this.canvas.getContext('2d').drawImage(this.image, 0, 0, this.props.size, this.props.size);
},
setImageRef (c) {
this.image = c;
},
setCanvasRef (c) {
this.canvas = c;
},
render () {
const { hovering } = this.state;
return (
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
<div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
<img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', visibility: hovering ? 'visible' : 'hidden', borderRadius: '4px' }} />
<canvas ref={this.setCanvasRef} width={this.props.size} height={this.props.size} style={{ borderRadius: '4px' }} />
</div>
);
}

+ 1
- 1
app/assets/javascripts/components/components/button.jsx View File

@ -27,7 +27,7 @@ const Button = React.createClass({
render () {
const style = {
fontFamily: 'Roboto',
fontFamily: 'inherit',
display: this.props.block ? 'block' : 'inline-block',
width: this.props.block ? '100%' : 'auto',
position: 'relative',

+ 60
- 0
app/assets/javascripts/components/components/column_collapsable.jsx View File

@ -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;

+ 4
- 2
app/assets/javascripts/components/components/dropdown_menu.jsx View File

@ -1,13 +1,15 @@
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
const DropdownMenu = ({ icon, items, size }) => {
const DropdownMenu = ({ icon, items, size, direction }) => {
const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right";
return (
<Dropdown>
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
<i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
</DropdownTrigger>
<DropdownContent style={{ lineHeight: '18px', textAlign: 'left' }}>
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
<ul>
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
if (typeof action === 'function') {

+ 17
- 5
app/assets/javascripts/components/components/icon_button.jsx View File

@ -1,4 +1,5 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Motion, spring } from 'react-motion';
const IconButton = React.createClass({
@ -10,14 +11,16 @@ const IconButton = React.createClass({
active: React.PropTypes.bool,
style: React.PropTypes.object,
activeStyle: React.PropTypes.object,
disabled: React.PropTypes.bool
disabled: React.PropTypes.bool,
animate: React.PropTypes.bool
},
getDefaultProps () {
return {
size: 18,
active: false,
disabled: false
disabled: false,
animate: false
};
},
@ -49,9 +52,18 @@ const IconButton = React.createClass({
}
return (
<button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) =>
<button
aria-label={this.props.title}
title={this.props.title}
className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
onClick={this.handleClick}
style={style}>
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
}
</Motion>
);
}

+ 11
- 11
app/assets/javascripts/components/components/lightbox.jsx View File

@ -35,7 +35,9 @@ const Lightbox = React.createClass({
propTypes: {
isVisible: React.PropTypes.bool,
onOverlayClicked: React.PropTypes.func,
onCloseClicked: React.PropTypes.func
onCloseClicked: React.PropTypes.func,
intl: React.PropTypes.object.isRequired,
children: React.PropTypes.node
},
mixins: [PureRenderMixin],
@ -57,19 +59,17 @@ const Lightbox = React.createClass({
render () {
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
const content = isVisible ? children : <div />;
return (
<div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}>
<Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}>
{({ y }) =>
<div style={{...dialogStyle, transform: `translateY(${y}px)`}}>
<Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
{({ backgroundOpacity, opacity, y }) =>
<div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
<div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
{content}
{children}
</div>
}
</Motion>
</div>
</div>
}
</Motion>
);
}

+ 12
- 10
app/assets/javascripts/components/components/loading_indicator.jsx View File

@ -1,15 +1,17 @@
import { FormattedMessage } from 'react-intl';
const LoadingIndicator = () => {
const style = {
textAlign: 'center',
fontSize: '16px',
fontWeight: '500',
color: '#616b86',
paddingTop: '120px'
};
return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>;
const style = {
textAlign: 'center',
fontSize: '16px',
fontWeight: '500',
color: '#616b86',
paddingTop: '120px'
};
const LoadingIndicator = () => (
<div style={style}>
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
</div>
);
export default LoadingIndicator;

+ 39
- 14
app/assets/javascripts/components/components/media_gallery.jsx View File

@ -1,12 +1,18 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { FormattedMessage } from 'react-intl';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
});
const outerStyle = {
marginTop: '8px',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box'
boxSizing: 'border-box',
position: 'relative'
};
const spoilerStyle = {
@ -32,11 +38,18 @@ const spoilerSubSpanStyle = {
fontWeight: '500'
};
const spoilerButtonStyle = {
position: 'absolute',
top: '6px',
left: '8px',
zIndex: '100'
};
const MediaGallery = React.createClass({
getInitialState () {
return {
visible: false
visible: !this.props.sensitive
};
},
@ -59,21 +72,30 @@ const MediaGallery = React.createClass({
},
handleOpen () {
this.setState({ visible: true });
this.setState({ visible: !this.state.visible });
},
render () {
const { media, sensitive } = this.props;
const { media, intl, sensitive } = this.props;
let children;
if (sensitive && !this.state.visible) {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
if (!this.state.visible) {
if (sensitive) {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
} else {
const size = media.take(4).size;
@ -134,9 +156,12 @@ const MediaGallery = React.createClass({
);
});
}
return (
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
<div style={spoilerButtonStyle} >
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
</div>
{children}
</div>
);
@ -144,4 +169,4 @@ const MediaGallery = React.createClass({
});
export default MediaGallery;
export default injectIntl(MediaGallery);

+ 17
- 0
app/assets/javascripts/components/components/missing_indicator.jsx View File

@ -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;

+ 12
- 9
app/assets/javascripts/components/components/relative_timestamp.jsx View File

@ -1,15 +1,18 @@
import {
FormattedMessage,
FormattedDate,
FormattedRelative
} from 'react-intl';
const RelativeTimestamp = ({ timestamp }) => {
return <FormattedRelative value={new Date(timestamp)} />;
import { injectIntl, FormattedRelative } from 'react-intl';
const RelativeTimestamp = ({ intl, timestamp }) => {
const date = new Date(timestamp);
return (
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
<FormattedRelative value={date} />
</time>
);
};
RelativeTimestamp.propTypes = {
intl: React.PropTypes.object.isRequired,
timestamp: React.PropTypes.string.isRequired
};
export default RelativeTimestamp;
export default injectIntl(RelativeTimestamp);

+ 3
- 3
app/assets/javascripts/components/components/status_action_bar.jsx View File

@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({
},
handleMentionClick () {
this.props.onMention(this.props.status.get('account'));
this.props.onMention(this.props.status.get('account'), this.context.router);
},
handleBlockClick () {
@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ width: '18px', height: '18px', float: 'left' }}>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" />
</div>
</div>
);

+ 55
- 7
app/assets/javascripts/components/components/status_content.jsx View File

@ -1,6 +1,7 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import emojify from '../emoji';
import { FormattedMessage } from 'react-intl';
const StatusContent = React.createClass({
@ -13,6 +14,12 @@ const StatusContent = React.createClass({
onClick: React.PropTypes.func
},
getInitialState () {
return {
hidden: true
};
},
mixins: [PureRenderMixin],
componentDidMount () {
@ -31,8 +38,6 @@ const StatusContent = React.createClass({
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
}
link.addEventListener('click', this.onNormalClick, false);
}
},
@ -52,16 +57,59 @@ const StatusContent = React.createClass({
}
},
onNormalClick (e) {
e.stopPropagation();
handleMouseDown (e) {
this.startXY = [e.clientX, e.clientY];
},
handleMouseUp (e) {
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0) {
this.props.onClick();
}
this.startXY = null;
},
handleSpoilerClick () {
this.setState({ hidden: !this.state.hidden });
},
render () {
const { status, onClick } = this.props;
const { status } = this.props;
const { hidden } = this.state;
const content = { __html: emojify(status.get('content')) };
return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />;
const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) };
if (status.get('spoiler_text').length > 0) {
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
return (
<div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden ? '0px' : '' }} >
<span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a>
</p>
<div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
</div>
);
} else {
return (
<div
className='status__content'
style={{ cursor: 'pointer' }}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
);
}
},
});

+ 27
- 10
app/assets/javascripts/components/components/status_list.jsx View File

@ -11,7 +11,8 @@ const StatusList = React.createClass({
onScrollToBottom: React.PropTypes.func,
onScrollToTop: React.PropTypes.func,
onScroll: React.PropTypes.func,
trackScroll: React.PropTypes.bool
trackScroll: React.PropTypes.bool,
isLoading: React.PropTypes.bool
},
getDefaultProps () {
@ -24,10 +25,10 @@ const StatusList = React.createClass({
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
this.props.onScrollToBottom();
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
@ -36,21 +37,37 @@ const StatusList = React.createClass({
}
},
componentDidUpdate (prevProps) {
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !==pan> this.props.statusIds.first() &;& this._oldScrollPosition) {
const node = ReactDOM.findDOMNode(this);
componentDidMount () {
this.attachScrollListener();
},
if (node.scrollTop > 0) {
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
}
componentDidUpdate (prevProps) {
class="k">if class="p">( class="k">this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
}
},
componentWillUnmount () {
this.detachScrollListener();
},
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
},
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
},
setRef (c) {
this.node = c;
},
render () {
const { statusIds, onScrollToBottom, trackScroll } = this.props;
const scrollableArea = (
<div className='scrollable' onScroll={this.handleScroll}>
<div className='scrollable' ref={this.setRef}>
<div>
{statusIds.map((statusId) => {
return <StatusContainer key={statusId} id={statusId} />;

+ 52
- 14
app/assets/javascripts/components/components/video_player.jsx View File

@ -4,7 +4,8 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }
});
const videoStyle = {
@ -20,7 +21,7 @@ const videoStyle = {
const muteStyle = {
position: 'absolute',
top: '10px',
left: '10px',
right: '10px',
opacity: '0.8',
zIndex: '5'
};
@ -35,7 +36,8 @@ const spoilerStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
flexDirection: 'column',
position: 'relative'
};
const spoilerSpanStyle = {
@ -49,6 +51,13 @@ const spoilerSubSpanStyle = {
fontWeight: '500'
};
const spoilerButtonStyle = {
position: 'absolute',
top: '6px',
left: '8px',
zIndex: '100'
};
const VideoPlayer = React.createClass({
propTypes: {
media: ImmutablePropTypes.map.isRequired,
@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({
getInitialState () {
return {
visible: false,
visible: !this.props.sensitive,
preview: true,
muted: true
};
},
@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({
},
handleOpen () {
this.setState({ visible: true });
this.setState({ preview: !this.state.preview });
},
handleVisibility () {
this.setState({
visible: !this.state.visible,
preview: true
});
},
render () {
const { media, intl, width, height, sensitive } = this.props;
if (sensitive && !this.state.visible) {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else if (!sensitive && !this.state.visible) {
let spoilerButton = (
<div style={spoilerButtonStyle} >
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
if (!this.state.visible) {
if (sensitive) {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}>
{spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
{spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview) {
return (
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
{spoilerButton}
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
</div>
);
@ -113,7 +150,8 @@ const VideoPlayer = React.createClass({
return (
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
{spoilerButton}
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div>
);

+ 11
- 1
app/assets/javascripts/components/containers/account_container.jsx View File

@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors';
import Account from '../components/account';
import {
followAccount,
unfollowAccount
unfollowAccount,
blockAccount,
unblockAccount
} from '../actions/accounts';
const makeMapStateToProps = () => {
@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({
} else {
dispatch(followAccount(account.get('id')));
}
},
onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
}
});

+ 20
- 19
app/assets/javascripts/components/containers/mastodon.jsx View File

@ -7,15 +7,13 @@ import {
refreshTimeline
} from '../actions/timelines';
import { updateNotifications } from '../actions/notifications';
import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import {
applyRouterMiddleware,
useRouterHistory,
Router,
Route,
IndexRedirect,
IndexRoute
} from 'react-router';
import { useScroll } from 'react-router-scroll';
@ -35,6 +33,8 @@ import Favourites from '../features/favourites';
import HashtagTimeline from '../features/hashtag_timeline';
import Notifications from '../features/notifications';
import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses';
import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
@ -44,9 +44,12 @@ import pt from 'react-intl/locale-data/pt';
import hu from 'react-intl/locale-data/hu';
import uk from 'react-intl/locale-data/uk';
import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store';
const store = configureStore();
store.dispatch(hydrateStore(window.INITIAL_STATE));
const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web'
});
@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
const Mastodon = React.createClass({
propTypes: {
token: React.PropTypes.string.isRequired,
timelines: React.PropTypes.object,
account: React.PropTypes.string,
locale: React.PropTypes.string.isRequired
},
mixins: [PureRenderMixin],
componentWillMount() {
const { token, account, locale } = this.props;
store.dispatch(setAccessToken(token));
store.dispatch(setAccountSelf(JSON.parse(account)));
const { locale } = this.props;
if (typeof App !== 'undefined') {
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
received (data) {
switch(data.type) {
case 'update':
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
case 'delete':
return store.dispatch(deleteFromTimelines(data.id));
case 'notification':
return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
case 'update':
store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
break;
case 'delete':
store.dispatch(deleteFromTimelines(data.id));
break;
case 'notification':
store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
break;
}
}
@ -107,14 +105,16 @@ const Mastodon = React.createClass({
<Provider store={store}>
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={UI}>
<IndexRoute component={GettingStarted} />
<IndexRedirect to="/getting-started" />
<Route path='getting-started' component={GettingStarted} />
<Route path='timelines/home' component={HomeTimeline} />
<Route path='timelines/mentions' component={MentionsTimeline} />
<Route path='timelines/public' component={PublicTimeline} />
<Route path='timelines/tag/:id' component={HashtagTimeline} />
<Route path='notifications' component={Notifications} />
<Route path='favourites' component={FavouritedStatuses} />
<Route path='statuses/new' component={Compose} />
<Route path='statuses/:statusId' component={Status} />
@ -128,6 +128,7 @@ const Mastodon = React.createClass({
</Route>
<Route path='follow_requests' component={FollowRequests} />
<Route path='*' component={GenericNotFound} />
</Route>
</Router>
</Provider>

+ 5
- 1
app/assets/javascripts/components/containers/status_container.jsx View File

@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts';
import { deleteStatus } from '../actions/statuses';
import { openMedia } from '../actions/modal';
import { createSelector } from 'reselect'
import { isMobile } from '../is_mobile'
const mapStateToProps = (state, props) => ({
statusBase: state.getIn(['statuses', props.id]),
@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(deleteStatus(status.get('id')));
},
onMention (account) {
onMention (account, router) {
dispatch(mentionCompose(account));
if (isMobile(window.innerWidth)) {
router.push('/statuses/new');
}
},
onOpenMedia (url) {

+ 1
- 1
app/assets/javascripts/components/emoji.jsx View File

@ -5,5 +5,5 @@ emojione.sprites = false;
emojione.imagePathPNG = '/emoji/';
export default function emojify(text) {
return emojione.unicodeToImage(text);
return emojione.toImage(text);
};

+ 1
- 1
app/assets/javascripts/components/features/account/components/action_bar.jsx View File

@ -66,7 +66,7 @@ const ActionBar = React.createClass({
return (
<div style={outerStyle}>
<div style={outerDropdownStyle}>
<DropdownMenu items={menu} icon='bars' size={24} />
<DropdownMenu items={menu} icon='bars' size={24} direction="right" />
</div>
<div style={outerLinksStyle}>

+ 2
- 2
app/assets/javascripts/components/features/account/components/header.jsx View File

@ -71,8 +71,8 @@ const Header = React.createClass({
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
</a>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
{actionBtn}

+ 10
- 1
app/assets/javascripts/components/features/account/index.jsx View File

@ -20,6 +20,7 @@ import LoadingIndicator from '../../components/loading_indicator';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
import { isMobile } from '../../is_mobile'
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
@ -34,11 +35,16 @@ const makeMapStateToProps = () => {
const Account = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
me: React.PropTypes.number.isRequired
me: React.PropTypes.number.isRequired,
children: React.PropTypes.node
},
mixins: [PureRenderMixin],
@ -71,6 +77,9 @@ const Account = React.createClass({
handleMention () {
this.props.dispatch(mentionCompose(this.props.account));
if (isMobile(window.innerWidth)) {
this.context.router.push('/statuses/new');
}
},
render () {

+ 7
- 4
app/assets/javascripts/components/features/account_timeline/index.jsx View File

@ -9,7 +9,8 @@ import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items']),
isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
me: state.getIn(['meta', 'me'])
});
@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list
statusIds: ImmutablePropTypes.list,
isLoading: React.PropTypes.bool,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({
},
render () {
const { statusIds, me } = this.props;
const { statusIds, isLoading, me } = this.props;
if (!statusIds) {
return <LoadingIndicator />;
}
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
return <StatusList statusIds={statusIds} isLoading={isLoading} me={me} onScrollToBottom={this.handleScrollToBottom} />
}
});

+ 42
- 5
app/assets/javascripts/components/features/compose/components/compose_form.jsx View File

@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
});
@ -25,6 +26,8 @@ const ComposeForm = React.createClass({
suggestion_token: React.PropTypes.string,
suggestions: ImmutablePropTypes.list,
sensitive: React.PropTypes.bool,
spoiler: React.PropTypes.bool,
spoiler_text: React.PropTypes.string,
unlisted: React.PropTypes.bool,
private: React.PropTypes.bool,
fileDropDate: React.PropTypes.instanceOf(Date),
@ -32,6 +35,7 @@ const ComposeForm = React.createClass({
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
media_count: React.PropTypes.number,
me: React.PropTypes.number,
onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
onCancelReply: React.PropTypes.func.isRequired,
@ -39,6 +43,8 @@ const ComposeForm = React.createClass({
onFetchSuggestions: React.PropTypes.func.isRequired,
onSuggestionSelected: React.PropTypes.func.isRequired,
onChangeSensitivity: React.PropTypes.func.isRequired,
onChangeSpoilerness: React.PropTypes.func.isRequired,
onChangeSpoilerText: React.PropTypes.func.isRequired,
onChangeVisibility: React.PropTypes.func.isRequired,
onChangeListability: React.PropTypes.func.isRequired,
},
@ -49,7 +55,7 @@ const ComposeForm = React.createClass({
this.props.onChange(e.target.value);
},
handleKeyUp (e) {
handleKeyDown (e) {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.props.onSubmit();
}
@ -76,6 +82,15 @@ const ComposeForm = React.createClass({
this.props.onChangeSensitivity(e.target.checked);
},
handleChangeSpoilerness (e) {
this.props.onChangeSpoilerness(e.target.checked);
this.props.onChangeSpoilerText('');
},
handleChangeSpoilerText (e) {
this.props.onChangeSpoilerText(e.target.value);
},
handleChangeVisibility (e) {
this.props.onChangeVisibility(e.target.checked);
},
@ -85,7 +100,14 @@ const ComposeForm = React.createClass({
},
componentDidUpdate (prevProps) {
if (prevProps.in_reply_to !== this.props.in_reply_to) {
if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
// If replying to zero or one users, places the cursor at the end of the textbox.
// If replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
const selectionStart = this.props.text.search(/\s/) + 1;
const selectionEnd = this.props.text.length;
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
}
},
@ -103,8 +125,18 @@ const ComposeForm = React.createClass({
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
}
let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
return (
<div style={{ padding: '10px' }}>
<Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}>
{({ opacity, height }) =>
<div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
</div>
}
</Motion>
{replyArea}
<AutosuggestTextarea
@ -115,7 +147,7 @@ const ComposeForm = React.createClass({
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyUp={this.handleKeyUp}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
@ -123,7 +155,7 @@ const ComposeForm = React.createClass({
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
<UploadButtonContainer style={{ paddingTop: '4px' }} />
</div>
@ -132,7 +164,12 @@ const ComposeForm = React.createClass({
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
</label>
<Motion defaultStyle={{ opacity: this.props.private ? 0 : 100, height: this.props.private ? 39.5 : 0 }} style={{ opacity: spring(this.props.private ? 0 : 100), height: spring(this.props.private ? 0 : 39.5) }}>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}>
<Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></span>
</label>
<Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
{({ opacity, height }) =>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
<Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />

+ 61
- 12
app/assets/javascripts/components/features/compose/components/drawer.jsx View File

@ -1,26 +1,75 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Link } from 'react-router';
import { injectIntl, defineMessages } from 'react-intl';
const style = {
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
});
const outerStyle = {
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflowY: 'hidden'
};
const innerStyle = {
boxSizing: 'border-box',
background: '#454b5e',
padding: '0',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto'
overflowY: 'auto',
flexGrow: '1'
};
const tabStyle = {
display: 'block',
flex: '1 1 auto',
padding: '15px',
paddingBottom: '13px',
color: '#9baec8',
textDecoration: 'none',
textAlign: 'center',
fontSize: '16px',
borderBottom: '2px solid transparent'
};
const Drawer = React.createClass({
const tabActiveStyle = {
color: '#2b90d9',
borderBottom: '2px solid #2b90d9'
};
mixins: [PureRenderMixin],
const Drawer = ({ children, withHeader, intl }) => {
let header = '';
render () {
return (
<div className='drawer' style={style}>
{this.props.children}
if (withHeader) {
header = (
<div className='drawer__header'>
<Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
<Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
<a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
<a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
</div>
);
}
});
return (
<div className='drawer' style={outerStyle}>
{header}
<div className='drawer__inner' style={innerStyle}>
{children}
</div>
</div>
);
};
Drawer.propTypes = {
withHeader: React.PropTypes.bool,
children: React.PropTypes.node,
intl: React.PropTypes.object
};
export default Drawer;
export default injectIntl(Drawer);

+ 2
- 2
app/assets/javascripts/components/features/compose/components/navigation_bar.jsx View File

@ -16,12 +16,12 @@ const NavigationBar = React.createClass({
render () {
return (
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
<div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.settings' defaultMessage='Settings' /></a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.public_timeline' defaultMessage='Public timeline' /></Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a>
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
</div>
);

+ 1
- 1
app/assets/javascripts/components/features/compose/components/search.jsx View File

@ -38,7 +38,7 @@ const inputStyle = {
border: 'none',
padding: '10px',
paddingRight: '30px',
fontFamily: 'Roboto',
fontFamily: 'inherit',
background: '#282c37',
color: '#9baec8',
fontSize: '14px',

+ 6
- 4
app/assets/javascripts/components/features/compose/components/upload_button.jsx View File

@ -11,7 +11,9 @@ const UploadButton = React.createClass({
propTypes: {
disabled: React.PropTypes.bool,
onSelectFile: React.PropTypes.func.isRequired,
style: React.PropTypes.object
style: React.PropTypes.object,
resetFileKey: React.PropTypes.number,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
@ -31,12 +33,12 @@ const UploadButton = React.createClass({
},
render () {
const { intl } = this.props;
const { intl, resetFileKey, disabled } = this.props;
return (
<div style={this.props.style}>
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} />
<input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
<input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
</div>
);
}

+ 9
- 4
app/assets/javascripts/components/features/compose/components/upload_form.jsx View File

@ -12,15 +12,20 @@ const UploadForm = React.createClass({
propTypes: {
media: ImmutablePropTypes.list.isRequired,
is_uploading: React.PropTypes.bool,
onRemoveFile: React.PropTypes.func.isRequired
onRemoveFile: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
render () {
const { intl } = this.props;
const { intl, media } = this.props;
const uploads = this.props.media.map(attachment => (
if (!media.size) {
return null;
}
const uploads = media.map(attachment => (
<div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
<div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
@ -29,7 +34,7 @@ const UploadForm = React.createClass({
));
return (
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}>
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
{uploads}
</div>
);

+ 14
- 1
app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx View File

@ -8,6 +8,8 @@ import {
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSensitivity,
changeComposeSpoilerness,
changeComposeSpoilerText,
changeComposeVisibility,
changeComposeListability
} from '../../../actions/compose';
@ -22,13 +24,16 @@ const makeMapStateToProps = () => {
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
sensitive: state.getIn(['compose', 'sensitive']),
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
unlisted: state.getIn(['compose', 'unlisted']),
private: state.getIn(['compose', 'private']),
fileDropDate: state.getIn(['compose', 'fileDropDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
media_count: state.getIn(['compose', 'media_attachments']).size
media_count: state.getIn(['compose', 'media_attachments']).size,
me: state.getIn(['compose', 'me'])
};
};
@ -65,6 +70,14 @@ const mapDispatchToProps = function (dispatch) {
dispatch(changeComposeSensitivity(checked));
},
onChangeSpoilerness (checked) {
dispatch(changeComposeSpoilerness(checked));
},
onChangeSpoilerText (checked) {
dispatch(changeComposeSpoilerText(checked));
},
onChangeVisibility (checked) {
dispatch(changeComposeVisibility(checked));
},

+ 5
- 3
app/assets/javascripts/components/features/compose/containers/navigation_container.jsx View File

@ -1,8 +1,10 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
const mapStateToProps = (state, props) => ({
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
});
const mapStateToProps = (state, props) => {
return {
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
};
};
export default connect(mapStateToProps)(NavigationBar);

+ 1
- 0
app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx View File

@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
resetFileKey: state.getIn(['compose', 'resetFileKey'])
});
const mapDispatchToProps = dispatch => ({

+ 3
- 2
app/assets/javascripts/components/features/compose/index.jsx View File

@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose';
const Compose = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired
dispatch: React.PropTypes.func.isRequired,
withHeader: React.PropTypes.bool
},
mixins: [PureRenderMixin],
@ -25,7 +26,7 @@ const Compose = React.createClass({
render () {
return (
<Drawer>
<Drawer withHeader={this.props.withHeader}>
<SearchContainer />
<NavigationContainer />
<ComposeFormContainer />

+ 63
- 0
app/assets/javascripts/components/features/favourited_statuses/index.jsx View File

@ -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));

+ 10
- 0
app/assets/javascripts/components/features/generic_not_found/index.jsx View File

@ -0,0 +1,10 @@
import Column from '../ui/components/column';
import MissingIndicator from '../../components/missing_indicator';
const GenericNotFound = () => (
<Column>
<MissingIndicator />
</Column>
);
export default GenericNotFound;

+ 14
- 21
app/assets/javascripts/components/features/getting_started/index.jsx View File

@ -8,25 +8,16 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
});
const hamburgerStyle = {
background: '#373b4a',
color: '#fff',
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '0',
top: '-48px',
cursor: 'default'
};
const GettingStarted = ({ intl, me }) => {
let followRequests = '';
@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => {
return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
<div style={{ position: 'relative' }}>
<div style={hamburgerStyle}><i className='fa fa-bars' /></div>
<ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
{followRequests}
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='static-content'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
<div className='scrollable optionally-scrollable'>
<div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
</div>
</div>
<div className='getting-started__illustration' />
</Column>
);
};

+ 68
- 0
app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx View File

@ -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);

+ 41
- 0
app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx View File

@ -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;

+ 21
- 0
app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx View File

@ -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);

+ 4
- 8
app/assets/javascripts/components/features/home_timeline/index.jsx View File

@ -1,9 +1,8 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../ui/components/column';
import { refreshTimeline } from '../../actions/timelines';
import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }
@ -12,20 +11,17 @@ const messages = defineMessages({
const HomeTimeline = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(refreshTimeline('home'));
},
render () {
const { intl } = this.props;
return (
<Column icon='home' heading={intl.formatMessage(messages.title)}>
<ColumnSettingsContainer />
<StatusListContainer {...this.props} type='home' />
</Column>
);
@ -33,4 +29,4 @@ const HomeTimeline = React.createClass({
});
export default connect()(injectIntl(HomeTimeline));
export default injectIntl(HomeTimeline);

+ 41
- 109
app/assets/javascripts/components/features/notifications/components/column_settings.jsx View File

@ -1,37 +1,14 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
import { Motion, spring } from 'react-motion';
import { FormattedMessage } from 'react-intl';
import ColumnCollapsable from '../../../components/column_collapsable';
import SettingToggle from './setting_toggle';
const outerStyle = {
background: '#373b4a',
padding: '15px'
};
const iconStyle = {
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '0',
top: '-48px',
cursor: 'pointer'
};
const labelStyle = {
display: 'block',
lineHeight: '24px',
verticalAlign: 'middle'
};
const labelSpanStyle = {
display: 'inline-block',
verticalAlign: 'middle',
marginBottom: '14px',
marginLeft: '8px',
color: '#9baec8'
};
const sectionStyle = {
cursor: 'default',
display: 'block',
@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({
propTypes: {
settings: ImmutablePropTypes.map.isRequired,
onChange: React.PropTypes.func.isRequired
},
getInitialState () {
return {
collapsed: true
};
onChange: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleToggleCollapsed () {
this.setState({ collapsed: !this.state.collapsed });
},
handleChange (key, e) {
this.props.onChange(key, e.target.checked);
},
render () {
const { settings } = this.props;
const { collapsed } = this.state;
const { settings, onChange, onSave } = this.props;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
return (
<div style={{ position: 'relative' }}>
<div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : 458) }}>
{({ opacity, height }) =>
<div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
<div style={outerStyle}>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'mention'])} onChange={this.handleChange.bind(this, ['shows', 'mention'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div style={rowStyle}>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['alerts', 'reblog'])} onChange={this.handleChange.bind(this, ['alerts', 'reblog'])} />
<span style={labelSpanStyle}>{alertStr}</span>
</label>
<label style={labelStyle}>
<Toggle checked={settings.getIn(['shows', 'reblog'])} onChange={this.handleChange.bind(this, ['shows', 'reblog'])} />
<span style={labelSpanStyle}>{showStr}</span>
</label>
</div>
</div>
</div>
}
</Motion>
</div>
<ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
<div style={outerStyle}>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
<span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
</div>
</ColumnCollapsable>
);
}

+ 5
- 2
app/assets/javascripts/components/features/notifications/components/notification.jsx View File

@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container';
import { FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink';
import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
const messageStyle = {
marginLeft: '68px',
@ -71,7 +73,7 @@ const Notification = React.createClass({
<i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
</div>
<FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} />
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div>
<StatusContainer id={notification.get('status')} muted={true} />
@ -83,7 +85,8 @@ const Notification = React.createClass({
const { notification } = this.props;
const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`}>{displayName}</Permalink>;
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
switch(notification.get('type')) {
case 'follow':

+ 32
- 0
app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx View File

@ -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;

+ 7
- 3
app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx View File

@ -1,15 +1,19 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeNotificationsSetting } from '../../../actions/notifications';
import { changeSetting, saveSettings } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['notifications', 'settings'])
settings: state.getIn(['settings', 'notifications'])
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeNotificationsSetting(key, checked));
dispatch(changeSetting(['notifications', ...key], checked));
},
onSave () {
dispatch(saveSettings());
}
});

+ 9
- 13
app/assets/javascripts/components/features/notifications/index.jsx View File

@ -2,10 +2,7 @@ import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
import {
refreshNotifications,
expandNotifications
} from '../../actions/notifications';
import { expandNotifications } from '../../actions/notifications';
import NotificationContainer from './containers/notification_container';
import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl } from 'react-intl';
@ -18,12 +15,13 @@ const messages = defineMessages({
});
const getNotifications = createSelector([
state => Immutable.List(state.getIn(['notifications', 'settings', 'shows']).filter(item => !item).keys()),
state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
state => state.getIn(['notifications', 'items'])
], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
const mapStateToProps = state => ({
notifications: getNotifications(state)
notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], true)
});
const Notifications = React.createClass({
@ -32,7 +30,8 @@ const Notifications = React.createClass({
notifications: ImmutablePropTypes.list.isRequired,
dispatch: React.PropTypes.func.isRequired,
trackScroll: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
intl: React.PropTypes.object.isRequired,
isLoading: React.PropTypes.bool
},
getDefaultProps () {
@ -43,15 +42,11 @@ const Notifications = React.createClass({
mixins: [PureRenderMixin],
componentWillMount () {
const { dispatch } = this.props;
dispatch(refreshNotifications());
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
if (scrollTop === scrollHeight - clientHeight) {
if (250 > offset && !this.props.isLoading) {
this.props.dispatch(expandNotifications());
}
},
@ -70,6 +65,7 @@ const Notifications = React.createClass({
if (trackScroll) {
return (
<Column icon='bell' heading={intl.formatMessage(messages.title)}>
<ColumnSettingsContainer />
<ScrollContainer scrollKey='notifications'>
{scrollableArea}
</ScrollContainer>

+ 2
- 2
app/assets/javascripts/components/features/status/components/action_bar.jsx View File

@ -61,8 +61,8 @@ const ActionBar = React.createClass({
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
</div>
);
}

+ 100
- 0
app/assets/javascripts/components/features/status/components/card.jsx View File

@ -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;

+ 11
- 2
app/assets/javascripts/components/features/status/components/detailed_status.jsx View File

@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery';
import VideoPlayer from '../../../components/video_player';
import { Link } from 'react-router';
import { FormattedDate, FormattedNumber } from 'react-intl';
import CardContainer from '../containers/card_container';
const DetailedStatus = React.createClass({
@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({
render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
let media = '';
let media = '';
let applicationLink = '';
if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({
} else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
}
} else {
media = <CardContainer statusId={status.get('id')} />;
}
if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>;
}
return (
@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({
{media}
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
</div>
</div>
);

+ 8
- 0
app/assets/javascripts/components/features/status/containers/card_container.jsx View File

@ -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);

+ 7
- 1
app/assets/javascripts/components/features/status/index.jsx View File

@ -23,6 +23,7 @@ import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container';
import { openMedia } from '../../actions/modal';
import { isMobile } from '../../is_mobile'
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
@ -47,7 +48,8 @@ const Status = React.createClass({
dispatch: React.PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list
descendantsIds: ImmutablePropTypes.list,
me: React.PropTypes.number
},
mixins: [PureRenderMixin],
@ -80,6 +82,10 @@ const Status = React.createClass({
handleMentionClick (account) {
this.props.dispatch(mentionCompose(account));
if (isMobile(window.innerWidth)) {
this.context.router.push('/statuses/new');
}
},
handleOpenMedia (url) {

+ 2
- 2
app/assets/javascripts/components/features/ui/components/column_link.jsx View File

@ -13,10 +13,10 @@ const iconStyle = {
marginRight: '5px'
};
const ColumnLink = ({ icon, text, to, href }) => {
const ColumnLink = ({ icon, text, to, href, method }) => {
if (href) {
return (
<a href={href} style={outerStyle} className='column-link'>
<a href={href} style={outerStyle} className='column-link' data-method={method}>
<i className={`fa fa-fw fa-${icon}`} style={iconStyle} />
{text}
</a>

+ 3
- 4
app/assets/javascripts/components/features/ui/components/tabs_bar.jsx View File

@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl';
const outerStyle = {
background: '#373b4a',
margin: '10px',
flex: '0 0 auto',
marginBottom: '0'
overflowY: 'auto'
};
const tabStyle = {
display: 'block',
flex: '1 1 auto',
padding: '10px',
padding: '10px 5px',
color: '#fff',
textDecoration: 'none',
textAlign: 'center',
@ -31,7 +30,7 @@ const TabsBar = () => {
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link>
<Link style={{ ...tabStyle, flexGrow: '0', flexBasis: '30px' }} activeStyle={tabActiveStyle} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
</div>
);
};

+ 25
- 4
app/assets/javascripts/components/features/ui/containers/modal_container.jsx View File

@ -1,6 +1,9 @@
import { connect } from 'react-redux';
import { closeModal } from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
import { connect } from 'react-redux';
import { closeModal } from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
import ImageLoader from 'react-imageloader';
import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin';
const mapStateToProps = state => ({
url: state.getIn(['modal', 'url']),
@ -23,6 +26,18 @@ const imageStyle = {
maxHeight: '80vh'
};
const loadingStyle = {
background: '#373b4a',
width: '400px',
paddingBottom: '120px'
};
const preloader = () => (
<div style={loadingStyle}>
<LoadingIndicator />
</div>
);
const Modal = React.createClass({
propTypes: {
@ -32,12 +47,18 @@ const Modal = React.createClass({
onOverlayClicked: React.PropTypes.func
},
mixins: [PureRenderMixin],
render () {
const { url, ...other } = this.props;
return (
<Lightbox {...other}>
<img src={url} style={imageStyle} />
<ImageLoader
src={url}
preloader={preloader}
imgProps={{ style: imageStyle }}
/>
</Lightbox>
);
}

+ 45
- 15
app/assets/javascripts/components/features/ui/containers/status_list_container.jsx View File

@ -2,26 +2,56 @@ import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
import Immutable from 'immutable';
import { createSelector } from 'reselect';
const getStatusIds = createSelector([
(state, { type }) => state.getIn(['settings', type], Immutable.Map()),
(state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
(state) => state.get('statuses')
], (columnSettings, statusIds, statuses) => statusIds.filter(id => {
const statusForId = statuses.get(id);
let showStatus = true;
if (columnSettings.getIn(['shows', 'reblog']) === false) {
showStatus = showStatus && statusForId.get('reblog') === null;
}
if (columnSettings.getIn(['shows', 'reply']) === false) {
showStatus = showStatus && statusForId.get('in_reply_to_id') === null;
}
if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
try {
const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content'));
} catch(e) {
// Bad regex, don't affect filters
}
}
return showStatus;
}));
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List())
statusIds: getStatusIds(state, props),
isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
});
const mapDispatchToProps = function (dispatch, props) {
return {
onScrollToBottom () {
dispatch(scrollTopTimeline(props.type, false));
dispatch(expandTimeline(props.type, props.id));
},
const mapDispatchToProps = (dispatch, { type, id }) => ({
onScrollToTop () {
dispatch(scrollTopTimeline(props.type, true));
},
onScrollToBottom () {
dispatch(scrollTopTimeline(type, false));
dispatch(expandTimeline(type, id));
},
onScroll () {
dispatch(scrollTopTimeline(props.type, false));
}
};
};
onScrollToTop () {
dispatch(scrollTopTimeline(type, true));
},
onScroll () {
dispatch(scrollTopTimeline(type, false));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);

+ 15
- 6
app/assets/javascripts/components/features/ui/index.jsx View File

@ -8,12 +8,20 @@ import Compose from '../compose';
import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container';
import Notifications from '../notifications';
import { connect } from 'react-redux';
import { isMobile } from '../../is_mobile';
import { debounce } from 'react-decoration';
import { uploadCompose } from '../../actions/compose';
import { connect } from 'react-redux';
import { refreshTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications';
const UI = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired,
children: React.PropTypes.node
},
getInitialState () {
return {
width: window.innerWidth
@ -41,7 +49,7 @@ const UI = React.createClass({
handleDrop (e) {
e.preventDefault();
if (e.dataTransfer) {
if (e.dataTransfer && e.dataTransfer.files.length === 1) {
this.props.dispatch(uploadCompose(e.dataTransfer.files));
}
},
@ -50,6 +58,9 @@ const UI = React.createClass({
window.addEventListener('resize', this.handleResize, { passive: true });
window.addEventListener('dragover', this.handleDragOver);
window.addEventListener('drop', this.handleDrop);
this.props.dispatch(refreshTimeline('home'));
this.props.dispatch(refreshNotifications());
},
componentWillUnmount () {
@ -59,11 +70,9 @@ const UI = React.createClass({
},
render () {
const layoutBreakpoint = 1024;
let mountedColumns;
if (this.state.width <= layoutBreakpoint) {
if (isMobile(this.state.width)) {
mountedColumns = (
<ColumnsArea>
{this.props.children}
@ -72,7 +81,7 @@ const UI = React.createClass({
} else {
mountedColumns = (
<ColumnsArea>
<Compose />
<Compose withHeader={true} />
<HomeTimeline trackScroll={false} />
<Notifications trackScroll={false} />
{this.props.children}

+ 5
- 0
app/assets/javascripts/components/is_mobile.jsx View File

@ -0,0 +1,5 @@
const LAYOUT_BREAKPOINT = 1024;
export function isMobile(width) {
return width <= LAYOUT_BREAKPOINT;
};

+ 25
- 2
app/assets/javascripts/components/locales/de.jsx View File

@ -8,6 +8,9 @@ const en = {
"status.reblog": "Teilen",
"status.favourite": "Favorisieren",
"status.reblogged_by": "{name} teilte",
"status.sensitive_warning": "Sensible Inhalte",
"status.sensitive_toggle": "Klicken um zu zeigen",
"status.open": "Öffnen",
"video_player.toggle_sound": "Ton umschalten",
"account.mention": "Erwähnen",
"account.edit_profile": "Profil bearbeiten",
@ -19,14 +22,17 @@ const en = {
"account.follows": "Folgt",
"account.followers": "Folger",
"account.follows_you": "Folgt dir",
"account.requested": "Warte auf Erlaubnis",
"getting_started.heading": "Erste Schritte",
"getting_started.about_addressing": "Du kannst Leuten folgen, falls du ihren Nutzernamen und ihre Domain kennst, in dem du eine e-mail-artige Addresse in das Suchfeld oben an der Seite eingibst.",
"getting_started.about_shortcuts": "Falls der Zielnutzer an derselben Domain ist wie du, funktioniert der Nutzername auch alleine. Das gilt auch für Erwähnungen in Beiträgen.",
"getting_started.about_developer": "Der Entwickler des Projekts kann unter Gargron@mastodon.social gefunden werden",
"getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
"column.home": "Home",
"column.mentions": "Erwähnungen",
"column.public": "Gesamtes Bekanntes Netz",
"column.notifications": "Mitteilungen",
"column.follow_requests": "Folgeanfragen",
"tabs_bar.compose": "Schreiben",
"tabs_bar.home": "Home",
"tabs_bar.mentions": "Erwähnungen",
@ -36,9 +42,12 @@ const en = {
"compose_form.publish": "Veröffentlichen",
"compose_form.sensitive": "Medien als sensitiv markieren",
"compose_form.unlisted": "Öffentlich nicht auflisten",
"navigation_bar.settings": "Einstellungen",
"compose_form.private": "Als privat markieren",
"navigation_bar.edit_profile": "Profil bearbeiten",
"navigation_bar.preferences": "Einstellungen",
"navigation_bar.public_timeline": "Öffentlich",
"navigation_bar.logout": "Abmelden",
"navigation_bar.follow_requests": "Folgeanfragen",
"reply_indicator.cancel": "Abbrechen",
"search.placeholder": "Suche",
"search.account": "Konto",
@ -48,7 +57,21 @@ const en = {
"notification.follow": "{name} folgt dir",
"notification.favourite": "{name} favorisierte deinen Status",
"notification.reblog": "{name} teilte deinen Status",
"notification.mention": "{name} erwähnte dich"
"notification.mention": "{name} erwähnte dich",
"notifications.column_settings.alert": "Desktop-Benachrichtigunen",
"notifications.column_settings.show": "In der Spalte anzeigen",
"notifications.column_settings.follow": "Neue Folger:",
"notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.mention": "Erwähnungen:",
"notifications.column_settings.reblog": "Geteilte Beiträge:",
"follow_request.authorize": "Erlauben",
"follow_request.reject": "Ablehnen",
"home.column_settings.basic": "Einfach",
"home.column_settings.advanced": "Fortgeschritten",
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
"home.column_settings.show_replies": "Antworten anzeigen",
"home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
"missing_indicator.label": "Nicht gefunden"
};
export default en;

+ 4
- 2
app/assets/javascripts/components/locales/en.jsx View File

@ -17,7 +17,6 @@ const en = {
"account.unfollow": "Unfollow",
"account.block": "Block",
"account.follow": "Follow",
"account.block": "Block",
"account.posts": "Posts",
"account.follows": "Follows",
"account.followers": "Followers",
@ -27,6 +26,7 @@ const en = {
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
"getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
"column.home": "Home",
"column.mentions": "Mentions",
"column.public": "Public",
@ -40,7 +40,9 @@ const en = {
"compose_form.publish": "Toot",
"compose_form.sensitive": "Mark media as sensitive",
"compose_form.private": "Mark as private",
"navigation_bar.settings": "Settings",
"compose_form.unlisted": "Do not display in public timeline",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Public timeline",
"navigation_bar.logout": "Logout",
"reply_indicator.cancel": "Cancel",

+ 2
- 1
app/assets/javascripts/components/locales/es.jsx View File

@ -37,7 +37,8 @@ const es = {
"compose_form.publish": "Publicar",
"compose_form.sensitive": "Marcar el contenido como sensible",
"compose_form.unlisted": "Privado",
"navigation_bar.settings": "Ajustes",
"navigation_bar.edit_profile": "Editar perfil",
"navigation_bar.preferences": "Preferencias",
"navigation_bar.public_timeline": "Público",
"navigation_bar.logout": "Cerrar sesión",
"reply_indicator.cancel": "Cancelar",

+ 2
- 1
app/assets/javascripts/components/locales/fr.jsx View File

@ -38,7 +38,8 @@ const fr = {
"compose_form.publish": "Pouet",
"compose_form.sensitive": "Marquer le contenu comme délicat",
"compose_form.unlisted": "Ne pas apparaître dans le fil public",
"navigation_bar.settings": "Paramètres",
"navigation_bar.edit_profile": "Modifier le profil",
"navigation_bar.preferences": "Préférences",
"navigation_bar.public_timeline": "Public",
"navigation_bar.logout": "Déconnexion",
"reply_indicator.cancel": "Annuler",

+ 2
- 1
app/assets/javascripts/components/locales/hu.jsx View File

@ -38,7 +38,8 @@ const hu = {
"compose_form.publish": "Tülk!",
"compose_form.sensitive": "Tartalom érzékenynek jelölése",
"compose_form.unlisted": "Listázatlan mód",
"navigation_bar.settings": "Beállítások",
"navigation_bar.edit_profile": "Profil szerkesztése",
"navigation_bar.preferences": "Beállítások",
"navigation_bar.public_timeline": "Nyilvános időfolyam",
"navigation_bar.logout": "Kijelentkezés",
"reply_indicator.cancel": "Mégsem",

+ 2
- 1
app/assets/javascripts/components/locales/pt.jsx View File

@ -36,7 +36,8 @@ const pt = {
"compose_form.publish": "Publicar",
"compose_form.sensitive": "Marcar conteúdo como sensível",
"compose_form.unlisted": "Modo não-listado",
"navigation_bar.settings": "Configurações",
"navigation_bar.edit_profile": "Editar perfil",
"navigation_bar.preferences": "Preferências",
"navigation_bar.public_timeline": "Timeline Pública",
"navigation_bar.logout": "Logout",
"reply_indicator.cancel": "Cancelar",

+ 2
- 1
app/assets/javascripts/components/locales/uk.jsx View File

@ -38,7 +38,8 @@ const uk = {
"compose_form.publish": "Дмухнути",
"compose_form.sensitive": "Непристойний зміст",
"compose_form.unlisted": "Таємний режим",
"navigation_bar.settings": "Налаштування",
"navigation_bar.edit_profile": "Редагувати профіль",
"navigation_bar.preferences": "Налаштування",
"navigation_bar.public_timeline": "Публічна стіна",
"navigation_bar.logout": "Вийти",
"reply_indicator.cancel": "Відмінити",

+ 1
- 1
app/assets/javascripts/components/middleware/errors.jsx View File

@ -23,7 +23,7 @@ export default function errorsMiddleware() {
dispatch(showAlert(title, message));
} else {
console.error(action.error);
dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details'));
dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
}
}
}

+ 25
- 0
app/assets/javascripts/components/middleware/loading_bar.jsx View 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);
};
};

+ 48
- 35
app/assets/javascripts/components/reducers/accounts.jsx View File

@ -1,5 +1,4 @@
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
@ -7,7 +6,9 @@ import {
FOLLOWING_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
FOLLOW_REQUESTS_FETCH_SUCCESS
FOLLOW_REQUESTS_FETCH_SUCCESS,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS
} from '../actions/accounts';
import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
import {
@ -33,6 +34,11 @@ import {
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS
} from '../actions/notifications';
import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
@ -67,38 +73,45 @@ const initialState = Immutable.Map();
export default function accounts(state = initialState, action) {
switch(action.type) {
case ACCOUNT_SET_SELF:
case ACCOUNT_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeAccount(state, action.account);
case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWERS_EXPAND_SUCCESS:
case FOLLOWING_FETCH_SUCCESS:
case FOLLOWING_EXPAND_SUCCESS:
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY:
case SEARCH_SUGGESTIONS_READY:
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeAccountFromStatus(state, action.response);
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
default:
return state;
case STORE_HYDRATE:
return state.merge(action.state.get('accounts'));
case ACCOUNT_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeAccount(state, action.account);
case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWERS_EXPAND_SUCCESS:
case FOLLOWING_FETCH_SUCCESS:
case FOLLOWING_EXPAND_SUCCESS:
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY:
case SEARCH_SUGGESTIONS_READY:
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeAccountFromStatus(state, action.response);
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
case ACCOUNT_FOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
case ACCOUNT_UNFOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
default:
return state;
}
};

+ 14
- 0
app/assets/javascripts/components/reducers/cards.jsx View 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;
}
};

+ 73
- 61
app/assets/javascripts/components/reducers/compose.jsx View File

@ -17,16 +17,20 @@ import {
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LISTABILITY_CHANGE
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const initialState = Immutable.Map({
mounted: false,
sensitive: false,
spoiler: false,
spoiler_text: '',
unlisted: false,
private: false,
text: '',
@ -38,7 +42,8 @@ const initialState = Immutable.Map({
media_attachments: Immutable.List(),
suggestion_token: null,
suggestions: Immutable.List(),
me: null
me: null,
resetFileKey: Math.floor((Math.random() * 0x10000))
});
function statusToTextMentions(state, status) {
@ -55,6 +60,8 @@ function statusToTextMentions(state, status) {
function clearAll(state) {
return state.withMutations(map => {
map.set('text', '');
map.set('spoiler', false);
map.set('spoiler_text', '');
map.set('is_submitting', false);
map.set('in_reply_to', null);
map.update('media_attachments', list => list.clear());
@ -65,6 +72,7 @@ function appendMedia(state, media) {
return state.withMutations(map => {
map.update('media_attachments', list => list.push(media));
map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
});
};
@ -80,7 +88,7 @@ function removeMedia(state, mediaId) {
const insertSuggestion = (state, position, token, completion) => {
return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`);
map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null);
map.update('suggestions', Immutable.List(), list => list.clear());
});
@ -88,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => {
export default function compose(state = initialState, action) {
switch(action.type) {
case COMPOSE_MOUNT:
return state.set('mounted', true);
case COMPOSE_UNMOUNT:
return state.set('mounted', false);
case COMPOSE_SENSITIVITY_CHANGE:
return state.set('sensitive', action.checked);
case COMPOSE_VISIBILITY_CHANGE:
return state.set('private', action.checked);
case COMPOSE_LISTABILITY_CHANGE:
return state.set('unlisted', action.checked);
case COMPOSE_CHANGE:
return state.set('text', action.text);
case COMPOSE_REPLY:
return state.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
});
case COMPOSE_REPLY_CANCEL:
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('text', '');
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
case COMPOSE_UPLOAD_REQUEST:
return state.withMutations(map => {
map.set('is_uploading', true);
map.set('fileDropDate', new Date());
});
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, Immutable.fromJS(action.media));
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
} else {
return state;
}
case ACCOUNT_SET_SELF:
return state.set('me', action.account.id).set('private', action.account.locked);
default:
case STORE_HYDRATE:
return state.merge(action.state.get('compose'));
case COMPOSE_MOUNT:
return state.set('mounted', true);
case COMPOSE_UNMOUNT:
return state.set('mounted', false);
case COMPOSE_SENSITIVITY_CHANGE:
return state.set('sensitive', action.checked);
case COMPOSE_SPOILERNESS_CHANGE:
return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
case COMPOSE_SPOILER_TEXT_CHANGE:
return state.set('spoiler_text', action.text);
case COMPOSE_VISIBILITY_CHANGE:
return state.set('private', action.checked);
case COMPOSE_LISTABILITY_CHANGE:
return state.set('unlisted', action.checked);
case COMPOSE_CHANGE:
return state.set('text', action.text);
case COMPOSE_REPLY:
return state.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
});
case COMPOSE_REPLY_CANCEL:
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('text', '');
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
case COMPOSE_UPLOAD_REQUEST:
return state.withMutations(map => {
map.set('is_uploading', true);
map.set('fileDropDate', new Date());
});
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, Immutable.fromJS(action.media));
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
} else {
return state;
}
default:
return state;
}
};

+ 7
- 1
app/assets/javascripts/components/reducers/index.jsx View File

@ -11,6 +11,9 @@ import statuses from './statuses';
import relationships from './relationships';
import search from './search';
import notifications from './notifications';
import settings from './settings';
import status_lists from './status_lists';
import cards from './cards';
export default combineReducers({
timelines,
@ -20,9 +23,12 @@ export default combineReducers({
loadingBar: loadingBarReducer,
modal,
user_lists,
status_lists,
accounts,
statuses,
relationships,
search,
notifications
notifications,
settings,
cards
});

+ 9
- 9
app/assets/javascripts/components/reducers/meta.jsx View File

@ -1,16 +1,16 @@
import { ACCESS_TOKEN_SET } from '../actions/meta';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
const initialState = Immutable.Map();
const initialState = Immutable.Map({
access_token: null,
me: null
});
export default function meta(state = initialState, action) {
switch(action.type) {
case ACCESS_TOKEN_SET:
return state.set('access_token', action.token);
case ACCOUNT_SET_SELF:
return state.set('me', action.account.id);
default:
return state;
case STORE_HYDRATE:
return state.merge(action.state.get('meta'));
default:
return state;
}
};

+ 9
- 9
app/assets/javascripts/components/reducers/modal.jsx View File

@ -8,14 +8,14 @@ const initialState = Immutable.Map({
export default function modal(state = initialState, action) {
switch(action.type) {
case MEDIA_OPEN:
return state.withMutations(map => {
map.set('url', action.url);
map.set('open', true);
});
case MODAL_CLOSE:
return state.set('open', false);
default:
return state;
case MEDIA_OPEN:
return state.withMutations(map => {
map.set('url', action.url);
map.set('open', true);
});
case MODAL_CLOSE:
return state.set('open', false);
default:
return state;
}
};

+ 29
- 31
app/assets/javascripts/components/reducers/notifications.jsx View File

@ -2,7 +2,10 @@ import {
NOTIFICATIONS_UPDATE,
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_SETTING_CHANGE
NOTIFICATIONS_REFRESH_REQUEST,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_REFRESH_FAIL,
NOTIFICATIONS_EXPAND_FAIL
} from '../actions/notifications';
import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
import Immutable from 'immutable';
@ -11,22 +14,7 @@ const initialState = Immutable.Map({
items: Immutable.List(),
next: null,
loaded: false,
settings: Immutable.Map({
alerts: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
}),
shows: Immutable.Map({
follow: true,
favourite: true,
reblog: true,
mention: true
})
})
isLoading: true
});
const notificationToMap = notification => Immutable.Map({
@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => {
items = items.set(i, notificationToMap(n));
});
return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true);
return state
.update('items', list => loaded ? list.unshift(...items) : list.push(...items))
.set('next', next)
.set('loaded', true)
.set('isLoading', false);
};
const appendNormalizedNotifications = (state, notifications, next) => {
@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => {
items = items.set(i, notificationToMap(n));
});
return state.update('items', list => list.push(...items)).set('next', next);
return state
.update('items', list => list.push(...items))
.set('next', next)
.set('isLoading', false);
};
const filterNotifications = (state, relationship) => {
@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => {
export default function notifications(state = initialState, action) {
switch(action.type) {
case NOTIFICATIONS_UPDATE:
return normalizeNotification(state, action.notification);
case NOTIFICATIONS_REFRESH_SUCCESS:
return normalizeNotifications(state, action.notifications, action.next);
case NOTIFICATIONS_EXPAND_SUCCESS:
return appendNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
case NOTIFICATIONS_SETTING_CHANGE:
return state.setIn(['settings', ...action.key], action.checked);
default:
return state;
case NOTIFICATIONS_REFRESH_REQUEST:
case NOTIFICATIONS_EXPAND_REQUEST:
case NOTIFICATIONS_REFRESH_FAIL:
case NOTIFICATIONS_EXPAND_FAIL:
return state.set('isLoading', true);
case NOTIFICATIONS_UPDATE:
return normalizeNotification(state, action.notification);
case NOTIFICATIONS_REFRESH_SUCCESS:
return normalizeNotifications(state, action.notifications, action.next);
case NOTIFICATIONS_EXPAND_SUCCESS:
return appendNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
default:
return state;
}
};

+ 1
- 1
app/assets/javascripts/components/reducers/search.jsx View File

@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => {
}
];
if (value.indexOf('@') === -1) {
if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
newSuggestions.push({
title: 'hashtag',
items: [

+ 46
- 0
app/assets/javascripts/components/reducers/settings.jsx View 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
- 0
app/assets/javascripts/components/reducers/status_lists.jsx View 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;
}
};

+ 37
- 31
app/assets/javascripts/components/reducers/statuses.jsx View File

@ -28,6 +28,10 @@ import {
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS
} from '../actions/notifications';
import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites';
import Immutable from 'immutable';
const normalizeStatus = (state, status) => {
@ -77,36 +81,38 @@ const initialState = Immutable.Map();
export default function statuses(state = initialState, action) {
switch(action.type) {
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeStatus(state, action.status);
case REBLOG_SUCCESS:
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, action.response);
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
case FAVOURITE_FAIL:
return state.setIn([action.status.get('id'), 'favourited'], false);
case REBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.setIn([action.status.get('id'), 'reblogged'], false);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case ACCOUNT_BLOCK_SUCCESS:
return filterStatuses(state, action.relationship);
default:
return state;
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
case NOTIFICATIONS_UPDATE:
return normalizeStatus(state, action.status);
case REBLOG_SUCCESS:
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, action.response);
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
case FAVOURITE_FAIL:
return state.setIn([action.status.get('id'), 'favourited'], false);
case REBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.setIn([action.status.get('id'), 'reblogged'], false);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case ACCOUNT_BLOCK_SUCCESS:
return filterStatuses(state, action.relationship);
default:
return state;
}
};

+ 63
- 28
app/assets/javascripts/components/reducers/timelines.jsx View File

@ -1,9 +1,12 @@
import {
TIMELINE_REFRESH_REQUEST,
TIMELINE_REFRESH_SUCCESS,
TIMELINE_REFRESH_FAIL,
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPAND_SUCCESS,
TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_FAIL,
TIMELINE_SCROLL_TOP
} from '../actions/timelines';
import {
@ -13,37 +16,43 @@ import {
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
import {
ACCOUNT_FETCH_SUCCESS,
ACCOUNT_TIMELINE_FETCH_REQUEST,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_FETCH_FAIL,
ACCOUNT_TIMELINE_EXPAND_REQUEST,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_FAIL,
ACCOUNT_BLOCK_SUCCESS
} from '../actions/accounts';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import Immutable from 'immutable';
const initialState = Immutable.Map({
home: Immutable.Map({
isLoading: false,
loaded: false,
top: true,
items: Immutable.List()
}),
mentions: Immutable.Map({
isLoading: false,
loaded: false,
top: true,
items: Immutable.List()
}),
public: Immutable.Map({
isLoading: false,
loaded: false,
top: true,
items: Immutable.List()
}),
tag: Immutable.Map({
isLoading: false,
id: null,
loaded: false,
top: true,
@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
});
state = state.setIn([timeline, 'loaded'], true);
state = state.setIn([timeline, 'isLoading'], false);
return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
};
@ -94,6 +104,8 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
moreIds = moreIds.set(i, status.get('id'));
});
state = state.setIn([timeline, 'isLoading'], false);
return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
};
@ -105,7 +117,10 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) =
ids = ids.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
.set('isLoading', false)
.set('loaded', true)
.update('items', Immutable.List(), list => (replace ? ids : list.unshift(...ids))));
};
const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
moreIds = moreIds.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
.set('isLoading', false)
.update('items', list => list.push(...moreIds)));
};
const updateTimeline = (state, timeline, status, references) => {
@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => {
return state;
};
const deleteStatus = (state, id, accountId, references) => {
const deleteStatus = (state, id, accountId, references, reblogOf) => {
if (reblogOf) {
// If we are deleting a reblog, just replace reblog with its original
return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item));
}
// Remove references from timelines
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
});
// Remove references from account timelines
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove references from context
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
@ -202,8 +224,11 @@ const resetTimeline = (state, timeline, id) => {
if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
state = state.update(timeline, map => map
.set('id', id)
.set('isLoading', true)
.set('loaded', false)
.update('items', list => list.clear()));
} else {
state = state.setIn([timeline, 'isLoading'], true);
}
return state;
@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => {
export default function timelines(state = initialState, action) {
switch(action.type) {
case TIMELINE_REFRESH_REQUEST:
return resetTimeline(state, action.timeline, action.id);
case TIMELINE_REFRESH_SUCCESS:
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_EXPAND_SUCCESS:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.accountId, action.references);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_BLOCK_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP:
return state.setIn([action.timeline, 'top'], action.top);
default:
return state;
case TIMELINE_REFRESH_REQUEST:
case TIMELINE_EXPAND_REQUEST:
return resetTimeline(state, action.timeline, action.id);
case TIMELINE_REFRESH_FAIL:
case TIMELINE_EXPAND_FAIL:
return state.setIn([action.timeline, 'isLoading'], false);
case TIMELINE_REFRESH_SUCCESS:
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_EXPAND_SUCCESS:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
case ACCOUNT_TIMELINE_FETCH_REQUEST:
case ACCOUNT_TIMELINE_EXPAND_REQUEST:
return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
case ACCOUNT_TIMELINE_FETCH_FAIL:
case ACCOUNT_TIMELINE_EXPAND_FAIL:
return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_BLOCK_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP:
return state.setIn([action.timeline, 'top'], action.top);
default:
return state;
}
};

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save