Merge pull request #3 from tootsuite/master

Updating to current
This commit is contained in:
Anthony Bellew 2017-01-25 20:53:57 -07:00 committed by GitHub
commit 3d890c4073
276 changed files with 4837 additions and 1332 deletions

1
.env.vagrant Normal file
View File

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

3
.gitignore vendored
View File

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

View File

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

10
Gemfile
View File

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

View File

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

2
Procfile Normal file
View File

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

View File

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

109
Vagrantfile vendored Normal file
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
app.json Normal file
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"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

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

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

View File

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

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

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

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

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()
});
};
};

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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;

View File

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

View File

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

View File

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

View File

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

View File

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

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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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', boxSizing: 'border-box',
background: '#454b5e',
padding: '0', padding: '0',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflowY: 'auto' overflowY: 'auto',
flexGrow: '1'
}; };
const Drawer = React.createClass({ const tabStyle = {
display: 'block',
flex: '1 1 auto',
padding: '15px',
paddingBottom: '13px',
color: '#9baec8',
textDecoration: 'none',
textAlign: 'center',
fontSize: '16px',
borderBottom: '2px solid transparent'
};
mixins: [PureRenderMixin], const tabActiveStyle = {
color: '#2b90d9',
borderBottom: '2px solid #2b90d9'
};
render () { const Drawer = ({ children, withHeader, intl }) => {
return ( let header = '';
<div className='drawer' style={style}>
{this.props.children} if (withHeader) {
header = (
<div className='drawer__header'>
<Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
<Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
<a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
<a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
</div> </div>
); );
} }
}); return (
<div className='drawer' style={outerStyle}>
{header}
export default Drawer; <div className='drawer__inner' style={innerStyle}>
{children}
</div>
</div>
);
};
Drawer.propTypes = {
withHeader: React.PropTypes.bool,
children: React.PropTypes.node,
intl: React.PropTypes.object
};
export default injectIntl(Drawer);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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;

View File

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

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

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;

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

View File

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

View File

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

View File

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

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;

View File

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

View File

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

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

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;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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