Compare commits
231 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
bf5f55a6bb | ||
|
aabf884c5f | ||
|
17903c6dae | ||
|
c5e03a2e0d | ||
|
448ab18a20 | ||
|
b13e7dda1f | ||
|
8d7fc5da6c | ||
|
9aecc0f48a | ||
|
86574ea524 | ||
|
b6832553ff | ||
|
cff0b03cbb | ||
|
2f21f4cc01 | ||
|
2d69bf4b3a | ||
|
bb4d1eb2e8 | ||
|
096bfbad96 | ||
|
11cbe49ffc | ||
|
dbe00a4156 | ||
|
0400734df7 | ||
|
a39a92bd22 | ||
|
bec47e40f5 | ||
|
d0d799f911 | ||
|
f5c6baf29d | ||
|
c3559d18a3 | ||
|
4a6cc46e81 | ||
|
23fc424b7a | ||
|
b835f4aa1c | ||
|
ea4170785a | ||
|
d14967e1c8 | ||
|
45230c56ab | ||
|
3ab193bc3f | ||
|
93212bc2c4 | ||
|
7bb28bf780 | ||
|
262adb9791 | ||
|
eb47409036 | ||
|
a698b767c1 | ||
|
48b9619439 | ||
|
62292797ec | ||
|
6471a548fe | ||
|
4ea13d8dc9 | ||
|
2463c72006 | ||
|
087b993892 | ||
|
6d5ef89356 | ||
|
98c3a5e9c3 | ||
|
6d26bfd147 | ||
|
0405ef977a | ||
|
159203a7bc | ||
|
1828df9bc0 | ||
|
abf664b560 | ||
|
829d189f44 | ||
|
3731230c6d | ||
|
e4671adc25 | ||
|
c003e70758 | ||
|
562044f36a | ||
|
0160d1d9b5 | ||
|
aff22bfdb5 | ||
|
4b357ecf98 | ||
|
d427df4a8a | ||
|
76a717d549 | ||
|
87aabeb7af | ||
|
77045d0886 | ||
|
bc1dba22c5 | ||
|
e7723ab662 | ||
|
e7cd6da938 | ||
|
5ff2762fcf | ||
|
53fb7b1aa8 | ||
|
a59d10d3df | ||
|
9467b900a2 | ||
|
0a6b5e2c17 | ||
|
d991869498 | ||
|
dd03118098 | ||
|
57304ac375 | ||
|
bab4904492 | ||
|
d88d148d89 | ||
|
3692051561 | ||
|
50aaf4667f | ||
|
7e35650398 | ||
|
c49f6290eb | ||
|
fa1cc2d05a | ||
|
f10feb2e70 | ||
|
e8ff4c8e56 | ||
|
7060bdf04b | ||
|
e0a197650a | ||
|
c913bdfc98 | ||
|
bfb6cc5f2c | ||
|
ac4f53a3a2 | ||
|
1c84d505c8 | ||
|
909d0d5e88 | ||
|
e5565a7e4a | ||
|
19615a2c37 | ||
|
c1a0633a12 | ||
|
de50eff6ac | ||
|
f8f40f15da | ||
|
61db14bcbe | ||
|
720d1f8f3d | ||
|
b746a931a5 | ||
|
abb8f5837e | ||
|
a9e40a3d80 | ||
|
17122df80d | ||
|
852c82435d | ||
|
4b0e3eb1fb | ||
|
19cfe64455 | ||
|
04bbc57690 | ||
|
8698cd3281 | ||
|
1d2175f73c | ||
|
f88b8ce757 | ||
|
aea151a0de | ||
|
43df35213e | ||
|
6f7c9774c7 | ||
|
2e0a38d07c | ||
|
d5e086a47b | ||
|
7bb72ff198 | ||
|
b62c31306e | ||
|
f8b9b0810d | ||
|
225ce8cfce | ||
|
ea44d89383 | ||
|
dd02fc0ec4 | ||
|
f3e5625d2d | ||
|
fdd30af595 | ||
|
6611e3a2ef | ||
|
4baca34a45 | ||
|
564e01eaf6 | ||
|
c9a7e6e1e3 | ||
|
6c05e3063a | ||
|
a782e3b39d | ||
|
53deeeca01 | ||
|
e865673175 | ||
|
b5c6d00afa | ||
|
13ee88926d | ||
|
f0f791bb76 | ||
|
0895ff414e | ||
|
de1f3aab86 | ||
|
1de2833f30 | ||
|
b8eda3026f | ||
|
4470330385 | ||
|
f9c9fef157 | ||
|
07e56d52b1 | ||
|
6394baff4d | ||
|
2a22d4076e | ||
|
2993370de0 | ||
|
db4671fd3f | ||
|
6e0b3ddb0d | ||
|
df2c0b8dad | ||
|
04bfd4262f | ||
|
7075cef8f9 | ||
|
492a682e34 | ||
|
67b35a601a | ||
|
aa9d48343d | ||
|
edefd87adf | ||
|
70ab6624f5 | ||
|
4d336cefac | ||
|
20f581f796 | ||
|
e21a3fe0cd | ||
|
91144d46ec | ||
|
244d1307a3 | ||
|
a3384b6ea6 | ||
|
cc54683694 | ||
|
ab7cb80dd5 | ||
|
44856fb641 | ||
|
7a6d95f70c | ||
|
9b195f5dd3 | ||
|
33f7e1cf99 | ||
|
157f03f8bd | ||
|
36e7eeb6b9 | ||
|
64302b3c99 | ||
|
160b5148ec | ||
|
c257b29d86 | ||
|
f7f3e6e3be | ||
|
cbfc12044d | ||
|
b113cf97fb | ||
|
2ddf4e09f9 | ||
|
cdc49c6b4b | ||
|
5e511acb82 | ||
|
45776b55b0 | ||
|
e2ff39bf5d | ||
|
ffaba617d2 | ||
|
8d8ef18bb6 | ||
|
4eeb7947bd | ||
|
71e7537330 | ||
|
ecd4042c20 | ||
|
e0a4455622 | ||
|
998f161e1d | ||
|
1a1b9bbbc0 | ||
|
d7c55853e9 | ||
|
77efdfa110 | ||
|
451733961b | ||
|
68eed8c61f | ||
|
87b618ab02 | ||
|
f49ed8c819 | ||
|
38ce960ff9 | ||
|
cfba03bd27 | ||
|
81065bc06c | ||
|
3306a5d524 | ||
|
dd5e724c3f | ||
|
f249a8c187 | ||
|
65ae9637d6 | ||
|
aec51e40ee | ||
|
5f737c7228 | ||
|
0634e8dee5 | ||
|
29e79f770f | ||
|
427ba27641 | ||
|
769b1ebbe0 | ||
|
22a8801dbc | ||
|
52d7f862d3 | ||
|
35de03fbe3 | ||
|
f40843d680 | ||
|
d85df27053 | ||
|
bef4d8dab8 | ||
|
b0168c8f3c | ||
|
ef9d4f4e06 | ||
|
1f650d327d | ||
|
06016453bd | ||
|
943c69c65d | ||
|
a4b8069cf5 | ||
|
e8a8703a4b | ||
|
296ce2d45a | ||
|
2af3abd279 | ||
|
05af66d6b9 | ||
|
d772db4344 | ||
|
3554d638b3 | ||
|
87ba52ad3f | ||
|
15d01a5e08 | ||
|
b304cc07d5 | ||
|
b60430fe8f | ||
|
8bdbe99d69 | ||
|
68402228f3 | ||
|
5cfc9efad3 | ||
|
2f5b205916 | ||
|
cdad5d322d | ||
|
4f654eb822 | ||
|
3298c7e1c8 | ||
|
038a407b9e |
@@ -3,3 +3,5 @@
|
||||
public/system
|
||||
public/assets
|
||||
node_modules
|
||||
storybook
|
||||
neo4j
|
||||
|
@@ -6,6 +6,8 @@ DB_USER=postgres
|
||||
DB_NAME=postgres
|
||||
DB_PASS=
|
||||
DB_PORT=5432
|
||||
NEO4J_HOST=neo4j
|
||||
NEO4J_PORT=7474
|
||||
|
||||
# Federation
|
||||
LOCAL_DOMAIN=example.com
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ public/assets
|
||||
.env
|
||||
.env.*
|
||||
node_modules/
|
||||
neo4j/
|
||||
|
54
.rubocop.yml
54
.rubocop.yml
@@ -1,14 +1,60 @@
|
||||
Rails:
|
||||
Enabled: true
|
||||
|
||||
Metrics/LineLength:
|
||||
Enabled: false
|
||||
|
||||
Style/PerlBackrefs:
|
||||
AutoCorrect: false
|
||||
|
||||
Style/ClassAndModuleChildren:
|
||||
Enabled: false
|
||||
|
||||
Documentation:
|
||||
Metrics/BlockNesting:
|
||||
Max: 2
|
||||
|
||||
Metrics/LineLength:
|
||||
AllowURI: true
|
||||
Enabled: false
|
||||
|
||||
Metrics/MethodLength:
|
||||
CountComments: false
|
||||
Max: 10
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Max: 100
|
||||
|
||||
Metrics/ParameterLists:
|
||||
Max: 4
|
||||
CountKeywordArgs: true
|
||||
|
||||
Style/AccessModifierIndentation:
|
||||
EnforcedStyle: indent
|
||||
|
||||
Style/CollectionMethods:
|
||||
Enabled: true
|
||||
PreferredMethods:
|
||||
find_all: 'select'
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
Style/DoubleNegation:
|
||||
Enabled: false
|
||||
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
|
||||
Style/SpaceInsideHashLiteralBraces:
|
||||
EnforcedStyle: space
|
||||
|
||||
Style/TrailingCommaInLiteral:
|
||||
EnforcedStyleForMultiline: 'comma'
|
||||
|
||||
Style/RegexpLiteral:
|
||||
Enabled: false
|
||||
|
||||
AllCops:
|
||||
TargetRubyVersion: 2.2
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
||||
- 'db/**/*'
|
||||
- 'app/views/**/*'
|
||||
- 'config/**/*'
|
||||
|
17
.travis.yml
17
.travis.yml
@@ -1,11 +1,18 @@
|
||||
language: ruby
|
||||
cache: bundler
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
|
||||
env:
|
||||
matrix:
|
||||
- TRAVIS_NODE_VERSION="4"
|
||||
global:
|
||||
- LOCAL_DOMAIN=cb6e6126.ngrok.io
|
||||
- LOCAL_HTTPS=true
|
||||
- RAILS_ENV=test
|
||||
- NEO4J_HOST=localhost
|
||||
- NEO4J_PORT=7575
|
||||
|
||||
addons:
|
||||
postgresql: 9.4
|
||||
@@ -19,11 +26,15 @@ services:
|
||||
bundler_args: --without development production --retry=3 --jobs=3
|
||||
|
||||
install:
|
||||
- npm install -g npm@2
|
||||
- npm install
|
||||
- nvm install $TRAVIS_NODE_VERSION
|
||||
- npm install -g npm@3
|
||||
- npm install -g yarn
|
||||
- bundle install
|
||||
- yarn install
|
||||
|
||||
before_script:
|
||||
- bundle exec rails db:create db:migrate
|
||||
|
||||
script: bundle exec rspec
|
||||
script:
|
||||
- bundle exec rspec
|
||||
- npm test
|
||||
|
@@ -3,7 +3,9 @@ FROM ruby:2.2.4
|
||||
ENV RAILS_ENV=production
|
||||
|
||||
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
|
||||
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs nodejs-legacy npm ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -sL https://deb.nodesource.com/setup_4.x | bash -
|
||||
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install -g npm@3 && npm install -g yarn
|
||||
RUN mkdir /mastodon
|
||||
|
||||
WORKDIR /mastodon
|
||||
@@ -13,7 +15,8 @@ ADD Gemfile.lock /mastodon/Gemfile.lock
|
||||
RUN bundle install --deployment --without test development
|
||||
|
||||
ADD package.json /mastodon/package.json
|
||||
RUN npm install
|
||||
ADD yarn.lock /mastodon/yarn.lock
|
||||
RUN yarn
|
||||
|
||||
ADD . /mastodon
|
||||
|
17
Dockerfile.neo4j
Normal file
17
Dockerfile.neo4j
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM neo4j:latest
|
||||
|
||||
ENV NEO4J_AUTH=none
|
||||
|
||||
RUN cd /var/lib/neo4j/plugins \
|
||||
&& wget http://products.graphaware.com/download/framework-server-community/graphaware-server-community-all-3.0.6.43.jar \
|
||||
&& wget http://products.graphaware.com/download/noderank/graphaware-noderank-3.0.6.43.3.jar
|
||||
RUN echo "dbms.unmanaged_extension_classes=com.graphaware.server=/graphaware" >> /var/lib/neo4j/conf/neo4j.conf
|
||||
RUN echo 'com.graphaware.runtime.enabled=true\n\
|
||||
com.graphaware.module.NR.1=com.graphaware.module.noderank.NodeRankModuleBootstrapper\n\
|
||||
com.graphaware.module.NR.maxTopRankNodes=10\n\
|
||||
com.graphaware.module.NR.dampingFactor=0.85\n\
|
||||
com.graphaware.module.NR.propertyKey=nodeRank\n'\
|
||||
>> /var/lib/neo4j/conf/neo4j.conf
|
||||
RUN echo 'com.graphaware.runtime.stats.disabled=true\n\
|
||||
com.graphaware.server.stats.disabled=true\n'\
|
||||
>> /var/lib/neo4j/conf/neo4j.conf
|
8
Gemfile
8
Gemfile
@@ -4,7 +4,6 @@ gem 'rails', '5.0.0.1'
|
||||
gem 'sass-rails', '~> 5.0'
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'coffee-rails', '~> 4.1.0'
|
||||
gem 'therubyracer', platforms: :ruby
|
||||
gem 'jquery-rails'
|
||||
gem 'jbuilder', '~> 2.0'
|
||||
gem 'sdoc', '~> 0.4.0', group: :doc
|
||||
@@ -12,11 +11,13 @@ gem 'puma'
|
||||
|
||||
gem 'hamlit-rails'
|
||||
gem 'pg'
|
||||
gem 'pghero'
|
||||
gem 'dotenv-rails'
|
||||
gem 'font-awesome-rails'
|
||||
|
||||
gem 'paperclip', '~> 4.3'
|
||||
gem 'paperclip-av-transcoder'
|
||||
gem 'aws-sdk', '< 2.0'
|
||||
|
||||
gem 'http'
|
||||
gem 'httplog'
|
||||
@@ -37,10 +38,14 @@ gem 'htmlentities'
|
||||
gem 'simple_form'
|
||||
gem 'will_paginate'
|
||||
gem 'rack-attack'
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'sidekiq'
|
||||
gem 'ledermann-rails-settings'
|
||||
gem 'neography'
|
||||
|
||||
gem 'react-rails'
|
||||
gem 'browserify-rails'
|
||||
gem 'autoprefixer-rails'
|
||||
|
||||
group :development, :test do
|
||||
gem 'rspec-rails'
|
||||
@@ -67,4 +72,5 @@ end
|
||||
group :production do
|
||||
gem 'rails_12factor'
|
||||
gem 'lograge'
|
||||
gem 'redis-rails'
|
||||
end
|
||||
|
65
Gemfile.lock
65
Gemfile.lock
@@ -42,8 +42,15 @@ GEM
|
||||
addressable (2.4.0)
|
||||
arel (7.1.1)
|
||||
ast (2.3.0)
|
||||
autoprefixer-rails (6.5.0.2)
|
||||
execjs
|
||||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-sdk (1.66.0)
|
||||
aws-sdk-v1 (= 1.66.0)
|
||||
aws-sdk-v1 (1.66.0)
|
||||
json (~> 1.4)
|
||||
nokogiri (>= 1.4.4)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
@@ -97,6 +104,7 @@ GEM
|
||||
dotenv (= 2.1.1)
|
||||
railties (>= 4.0, < 5.1)
|
||||
erubis (2.7.0)
|
||||
excon (0.53.0)
|
||||
execjs (2.7.0)
|
||||
fabrication (2.15.2)
|
||||
fast_blank (1.0.0)
|
||||
@@ -107,7 +115,7 @@ GEM
|
||||
ruby-progressbar (~> 1.4)
|
||||
globalid (0.3.7)
|
||||
activesupport (>= 4.1.0)
|
||||
goldfinger (1.0.5)
|
||||
goldfinger (1.1.0)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
@@ -145,9 +153,10 @@ GEM
|
||||
json (1.8.3)
|
||||
launchy (2.4.3)
|
||||
addressable (~> 2.3)
|
||||
ledermann-rails-settings (2.4.2)
|
||||
activerecord (>= 3.1)
|
||||
letter_opener (1.4.1)
|
||||
launchy (~> 2.2)
|
||||
libv8 (3.16.14.15)
|
||||
link_header (0.0.8)
|
||||
lograge (0.4.1)
|
||||
actionpack (>= 4, < 5.1)
|
||||
@@ -163,15 +172,22 @@ GEM
|
||||
mime-types-data (3.2016.0521)
|
||||
mimemagic (0.3.0)
|
||||
mini_portile2 (2.1.0)
|
||||
minitest (5.9.0)
|
||||
minitest (5.9.1)
|
||||
multi_json (1.12.1)
|
||||
neography (1.8.0)
|
||||
excon (>= 0.33.0)
|
||||
json (>= 1.7.7)
|
||||
multi_json (>= 1.3.2)
|
||||
os (>= 0.9.6)
|
||||
rake (>= 0.8.7)
|
||||
rubyzip (>= 1.0.0)
|
||||
nio4r (1.2.1)
|
||||
nokogiri (1.6.8)
|
||||
nokogiri (1.6.8.1)
|
||||
mini_portile2 (~> 2.1.0)
|
||||
pkg-config (~> 1.1.7)
|
||||
oj (2.17.3)
|
||||
orm_adapter (0.5.0)
|
||||
ostatus2 (0.3.1)
|
||||
os (0.9.6)
|
||||
ostatus2 (1.0.2)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
@@ -187,7 +203,8 @@ GEM
|
||||
parser (2.3.1.2)
|
||||
ast (~> 2.2)
|
||||
pg (0.18.4)
|
||||
pkg-config (1.1.7)
|
||||
pghero (1.6.2)
|
||||
activerecord
|
||||
powerpack (0.1.1)
|
||||
pry (0.10.4)
|
||||
coderay (~> 1.1.0)
|
||||
@@ -196,11 +213,12 @@ GEM
|
||||
pry-rails (0.3.4)
|
||||
pry (>= 0.9.10)
|
||||
puma (3.6.0)
|
||||
rabl (0.13.0)
|
||||
rabl (0.13.1)
|
||||
activesupport (>= 2.3.14)
|
||||
rack (2.0.1)
|
||||
rack-attack (5.0.1)
|
||||
rack
|
||||
rack-cors (0.4.0)
|
||||
rack-protection (1.5.3)
|
||||
rack
|
||||
rack-test (0.6.3)
|
||||
@@ -236,7 +254,7 @@ GEM
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.18.1, < 2.0)
|
||||
rainbow (2.1.0)
|
||||
rake (11.2.2)
|
||||
rake (11.3.0)
|
||||
rdoc (4.2.2)
|
||||
json (~> 1.4)
|
||||
react-rails (1.8.2)
|
||||
@@ -247,7 +265,22 @@ GEM
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
redis (3.3.1)
|
||||
ref (2.0.0)
|
||||
redis-actionpack (5.0.0)
|
||||
actionpack (>= 4.0.0, < 6)
|
||||
redis-rack (~> 2.0.0.pre)
|
||||
redis-store (~> 1.2.0.pre)
|
||||
redis-activesupport (5.0.1)
|
||||
activesupport (>= 3, < 6)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-rack (2.0.0)
|
||||
rack (~> 2.0)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-rails (5.0.1)
|
||||
redis-actionpack (~> 5.0.0)
|
||||
redis-activesupport (~> 5.0.0)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-store (1.2.0)
|
||||
redis (>= 2.2)
|
||||
responders (2.3.0)
|
||||
railties (>= 4.2.0, < 5.1)
|
||||
rspec (3.5.0)
|
||||
@@ -281,6 +314,7 @@ GEM
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (~> 1.0, >= 1.0.1)
|
||||
ruby-progressbar (1.8.1)
|
||||
rubyzip (1.2.0)
|
||||
safe_yaml (1.0.4)
|
||||
sass (3.4.22)
|
||||
sass-rails (5.0.6)
|
||||
@@ -314,9 +348,6 @@ GEM
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
temple (0.7.7)
|
||||
therubyracer (0.12.2)
|
||||
libv8 (~> 3.16.14.0)
|
||||
ref
|
||||
thor (0.19.1)
|
||||
thread_safe (0.3.5)
|
||||
tilt (2.0.5)
|
||||
@@ -346,6 +377,8 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
active_record_query_trace
|
||||
addressable
|
||||
autoprefixer-rails
|
||||
aws-sdk (< 2.0)
|
||||
better_errors
|
||||
binding_of_caller
|
||||
browserify-rails
|
||||
@@ -366,24 +399,29 @@ DEPENDENCIES
|
||||
httplog
|
||||
jbuilder (~> 2.0)
|
||||
jquery-rails
|
||||
ledermann-rails-settings
|
||||
letter_opener
|
||||
link_header
|
||||
lograge
|
||||
neography
|
||||
nokogiri
|
||||
oj
|
||||
ostatus2
|
||||
paperclip (~> 4.3)
|
||||
paperclip-av-transcoder
|
||||
pg
|
||||
pghero
|
||||
pry-rails
|
||||
puma
|
||||
rabl
|
||||
rack-attack
|
||||
rack-cors
|
||||
rails (= 5.0.0.1)
|
||||
rails_12factor
|
||||
rails_autolink
|
||||
react-rails
|
||||
redis (~> 3.2)
|
||||
redis-rails
|
||||
rspec-rails
|
||||
rspec-sidekiq
|
||||
rubocop
|
||||
@@ -392,7 +430,6 @@ DEPENDENCIES
|
||||
sidekiq
|
||||
simple_form
|
||||
simplecov
|
||||
therubyracer
|
||||
uglifier (>= 1.3.0)
|
||||
webmock
|
||||
will_paginate
|
||||
|
21
README.md
21
README.md
@@ -7,11 +7,13 @@ Mastodon
|
||||
[travis]: https://travis-ci.org/Gargron/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
|
||||
|
||||
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
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.
|
||||
|
||||
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
|
||||
Click on the screenshot to watch a demo of the UI:
|
||||
|
||||
[][youtube_demo]
|
||||
[][youtube_demo]
|
||||
|
||||
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
||||
|
||||
@@ -21,12 +23,13 @@ If you would like, you can [support the development of this project on Patreon][
|
||||
|
||||
[patreon]: https://www.patreon.com/user?u=619786
|
||||
|
||||
**Current status of the project is early development**
|
||||
|
||||
## Resources
|
||||
|
||||
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
|
||||
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
|
||||
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
|
||||
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
|
||||
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -57,6 +60,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- Neo4J (optional)
|
||||
- GraphAware NodeRank
|
||||
|
||||
## Running with Docker and Docker-Compose
|
||||
|
||||
@@ -86,6 +91,7 @@ The container has two volumes, for the assets and for user uploads. The default
|
||||
- `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
|
||||
- `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
|
||||
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
|
||||
- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
|
||||
|
||||
Running any of these tasks via docker-compose would look like this:
|
||||
|
||||
@@ -110,3 +116,10 @@ Which will re-create the updated containers, leaving databases and data as is. D
|
||||
### Contributing
|
||||
|
||||
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
|
||||
|
||||
### Extra credits
|
||||
|
||||
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
|
||||
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
|
||||
|
||||

|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
BIN
app/assets/images/screenshot.png
Normal file
BIN
app/assets/images/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 346 KiB |
2
app/assets/javascripts/application_public.js
Normal file
2
app/assets/javascripts/application_public.js
Normal file
@@ -0,0 +1,2 @@
|
||||
//= require jquery
|
||||
//= require jquery_ujs
|
@@ -3,6 +3,7 @@
|
||||
|
||||
window.React = require('react');
|
||||
window.ReactDOM = require('react-dom');
|
||||
window.Perf = require('react-addons-perf');
|
||||
|
||||
//= require_tree ./components
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import api from '../api'
|
||||
import axios from 'axios';
|
||||
import api from '../api'
|
||||
import Immutable from 'immutable';
|
||||
|
||||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
||||
|
||||
@@ -31,6 +31,18 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'
|
||||
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
|
||||
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
|
||||
|
||||
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
|
||||
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
|
||||
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
|
||||
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
|
||||
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
|
||||
|
||||
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
|
||||
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
|
||||
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
export function setAccountSelf(account) {
|
||||
return {
|
||||
type: ACCOUNT_SET_SELF,
|
||||
@@ -40,24 +52,32 @@ export function setAccountSelf(account) {
|
||||
|
||||
export function fetchAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
const boundApi = api(getState);
|
||||
|
||||
dispatch(fetchAccountRequest(id));
|
||||
|
||||
axios.all([boundApi.get(`/api/v1/accounts/${id}`), boundApi.get(`/api/v1/accounts/relationships?id=${id}`)]).then(values => {
|
||||
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0]));
|
||||
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
||||
dispatch(fetchAccountSuccess(response.data));
|
||||
dispatch(fetchRelationships([id]));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimeline(id) {
|
||||
export function fetchAccountTimeline(id, replace = false) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchAccountTimelineRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => {
|
||||
dispatch(fetchAccountTimelineSuccess(id, response.data));
|
||||
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
|
||||
const newestId = ids.size > 0 ? ids.first() : null;
|
||||
|
||||
let params = '';
|
||||
|
||||
if (newestId !== null && !replace) {
|
||||
params = `?since_id=${newestId}`;
|
||||
}
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
|
||||
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountTimelineFail(id, error));
|
||||
});
|
||||
@@ -66,7 +86,7 @@ export function fetchAccountTimeline(id) {
|
||||
|
||||
export function expandAccountTimeline(id) {
|
||||
return (dispatch, getState) => {
|
||||
const lastId = getState().getIn(['timelines', 'accounts_timelines', id]).last();
|
||||
const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
|
||||
|
||||
dispatch(expandAccountTimelineRequest(id));
|
||||
|
||||
@@ -85,11 +105,10 @@ export function fetchAccountRequest(id) {
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountSuccess(account, relationship) {
|
||||
export function fetchAccountSuccess(account) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_SUCCESS,
|
||||
account: account,
|
||||
relationship: relationship
|
||||
account: account
|
||||
};
|
||||
};
|
||||
|
||||
@@ -174,11 +193,12 @@ export function fetchAccountTimelineRequest(id) {
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimelineSuccess(id, statuses) {
|
||||
export function fetchAccountTimelineSuccess(id, statuses, replace) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||
id: id,
|
||||
statuses: statuses
|
||||
statuses: statuses,
|
||||
replace: replace
|
||||
};
|
||||
};
|
||||
|
||||
@@ -278,3 +298,108 @@ export function unblockAccountFail(error) {
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowersRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
|
||||
dispatch(fetchFollowersSuccess(id, response.data));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFollowersFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersRequest(id) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_REQUEST,
|
||||
id: id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersSuccess(id, accounts) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_SUCCESS,
|
||||
id: id,
|
||||
accounts: accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_FAIL,
|
||||
id: id,
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowing(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowingRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
|
||||
dispatch(fetchFollowingSuccess(id, response.data));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFollowingFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingRequest(id) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_REQUEST,
|
||||
id: id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingSuccess(id, accounts) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_SUCCESS,
|
||||
id: id,
|
||||
accounts: accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_FAIL,
|
||||
id: id,
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationships(account_ids) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchRelationshipsRequest(account_ids));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsRequest(ids) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_REQUEST,
|
||||
ids: ids
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsSuccess(relationships) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_SUCCESS,
|
||||
relationships: relationships
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsFail(error) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_FAIL,
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
@@ -1,17 +1,23 @@
|
||||
import api from '../api'
|
||||
|
||||
import { updateTimeline } from './timelines';
|
||||
|
||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
||||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||
|
||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||
|
||||
export function changeCompose(text) {
|
||||
return {
|
||||
type: COMPOSE_CHANGE,
|
||||
@@ -32,6 +38,13 @@ export function cancelReplyCompose() {
|
||||
};
|
||||
};
|
||||
|
||||
export function mentionCompose(account) {
|
||||
return {
|
||||
type: COMPOSE_MENTION,
|
||||
account: account
|
||||
};
|
||||
};
|
||||
|
||||
export function submitCompose() {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(submitComposeRequest());
|
||||
@@ -42,6 +55,7 @@ export function submitCompose() {
|
||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
|
||||
}).then(function (response) {
|
||||
dispatch(submitComposeSuccess(response.data));
|
||||
dispatch(updateTimeline('home', response.data));
|
||||
}).catch(function (error) {
|
||||
dispatch(submitComposeFail(error));
|
||||
});
|
||||
@@ -121,3 +135,27 @@ export function undoUploadCompose(media_id) {
|
||||
media_id: media_id
|
||||
};
|
||||
};
|
||||
|
||||
export function clearComposeSuggestions() {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_CLEAR
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({
|
||||
label: item.get('acct'),
|
||||
completion: item.get('acct').slice(token.length)
|
||||
})).toList().toJS();
|
||||
|
||||
dispatch(readyComposeSuggestions(loadedCandidates));
|
||||
};
|
||||
};
|
||||
|
||||
export function readyComposeSuggestions(accounts) {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_READY,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
@@ -16,6 +16,14 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||
|
||||
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
|
||||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export function reblog(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(reblogRequest(status));
|
||||
@@ -157,3 +165,71 @@ export function unfavouriteFail(status, error) {
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
|
||||
dispatch(fetchReblogsSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReblogsFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsRequest(id) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsSuccess(id, accounts) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavourites(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFavouritesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesRequest(id) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
15
app/assets/javascripts/components/actions/modal.jsx
Normal file
15
app/assets/javascripts/components/actions/modal.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const MEDIA_OPEN = 'MEDIA_OPEN';
|
||||
export const MODAL_CLOSE = 'MODAL_CLOSE';
|
||||
|
||||
export function openMedia(url) {
|
||||
return {
|
||||
type: MEDIA_OPEN,
|
||||
url: url
|
||||
};
|
||||
};
|
||||
|
||||
export function closeModal() {
|
||||
return {
|
||||
type: MODAL_CLOSE
|
||||
};
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW';
|
||||
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
|
||||
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
|
||||
|
||||
@@ -13,3 +14,11 @@ export function clearNotifications() {
|
||||
type: NOTIFICATION_CLEAR
|
||||
};
|
||||
};
|
||||
|
||||
export function showNotification(title, message) {
|
||||
return {
|
||||
type: NOTIFICATION_SHOW,
|
||||
title: title,
|
||||
message: message
|
||||
};
|
||||
};
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import api from '../api';
|
||||
import axios from 'axios';
|
||||
import api from '../api';
|
||||
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
@@ -9,6 +10,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||
|
||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||
|
||||
export function fetchStatusRequest(id) {
|
||||
return {
|
||||
type: STATUS_FETCH_REQUEST,
|
||||
@@ -18,12 +23,11 @@ export function fetchStatusRequest(id) {
|
||||
|
||||
export function fetchStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
const boundApi = api(getState);
|
||||
|
||||
dispatch(fetchStatusRequest(id));
|
||||
|
||||
axios.all([boundApi.get(`/api/v1/statuses/${id}`), boundApi.get(`/api/v1/statuses/${id}/context`)]).then(values => {
|
||||
dispatch(fetchStatusSuccess(values[0].data, values[1].data));
|
||||
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch(fetchStatusSuccess(response.data));
|
||||
dispatch(fetchContext(id));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error));
|
||||
});
|
||||
@@ -52,6 +56,7 @@ export function deleteStatus(id) {
|
||||
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
});
|
||||
@@ -79,3 +84,40 @@ export function deleteStatusFail(id, error) {
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContext(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchContextRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||
}).catch(error => {
|
||||
dispatch(fetchContextFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextRequest(id) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_SUCCESS,
|
||||
id,
|
||||
ancestors,
|
||||
descendants,
|
||||
statuses: ancestors.concat(descendants)
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextFail(id, error) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
37
app/assets/javascripts/components/actions/suggestions.jsx
Normal file
37
app/assets/javascripts/components/actions/suggestions.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import api from '../api';
|
||||
|
||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||
|
||||
export function fetchSuggestions() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchSuggestionsRequest());
|
||||
|
||||
api(getState).get('/api/v1/accounts/suggestions').then(response => {
|
||||
dispatch(fetchSuggestionsSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSuggestionsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsRequest() {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsSuccess(accounts) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||
accounts: accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsFail(error) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_FAIL,
|
||||
error: error
|
||||
};
|
||||
};
|
@@ -1,4 +1,5 @@
|
||||
import api from '../api'
|
||||
import Immutable from 'immutable';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
@@ -11,26 +12,39 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
||||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
|
||||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||
|
||||
export function refreshTimelineSuccess(timeline, statuses) {
|
||||
export function refreshTimelineSuccess(timeline, statuses, replace) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_SUCCESS,
|
||||
timeline: timeline,
|
||||
statuses: statuses
|
||||
statuses: statuses,
|
||||
replace: replace
|
||||
};
|
||||
};
|
||||
|
||||
export function updateTimeline(timeline, status) {
|
||||
return {
|
||||
type: TIMELINE_UPDATE,
|
||||
timeline: timeline,
|
||||
status: status
|
||||
return (dispatch, getState) => {
|
||||
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
timeline,
|
||||
status,
|
||||
references
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteFromTimelines(id) {
|
||||
return {
|
||||
type: TIMELINE_DELETE,
|
||||
id: id
|
||||
return (dispatch, getState) => {
|
||||
const accountId = getState().getIn(['statuses', id, 'account']);
|
||||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_DELETE,
|
||||
id,
|
||||
accountId,
|
||||
references
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -41,12 +55,26 @@ export function refreshTimelineRequest(timeline) {
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshTimeline(timeline) {
|
||||
export function refreshTimeline(timeline, replace = false, id = null) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(refreshTimelineRequest(timeline));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
|
||||
dispatch(refreshTimelineSuccess(timeline, response.data));
|
||||
const ids = getState().getIn(['timelines', timeline], Immutable.List());
|
||||
const newestId = ids.size > 0 ? ids.first() : null;
|
||||
|
||||
let params = '';
|
||||
let path = timeline;
|
||||
|
||||
if (newestId !== null && !replace) {
|
||||
params = `?since_id=${newestId}`;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
path = `${path}/${id}`
|
||||
}
|
||||
|
||||
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
|
||||
dispatch(refreshTimelineSuccess(timeline, response.data, replace));
|
||||
}).catch(function (error) {
|
||||
dispatch(refreshTimelineFail(timeline, error));
|
||||
});
|
||||
@@ -61,13 +89,19 @@ export function refreshTimelineFail(timeline, error) {
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimeline(timeline) {
|
||||
export function expandTimeline(timeline, id = null) {
|
||||
return (dispatch, getState) => {
|
||||
const lastId = getState().getIn(['timelines', timeline]).last();
|
||||
const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last();
|
||||
|
||||
dispatch(expandTimelineRequest(timeline));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => {
|
||||
let path = timeline;
|
||||
|
||||
if (id) {
|
||||
path = `${path}/${id}`
|
||||
}
|
||||
|
||||
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
|
||||
dispatch(expandTimelineSuccess(timeline, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timeline, error));
|
||||
|
@@ -0,0 +1,40 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
|
||||
const outerStyle = {
|
||||
padding: '15px',
|
||||
fontSize: '16px',
|
||||
background: '#2f3441',
|
||||
flex: '0 0 auto',
|
||||
cursor: 'pointer',
|
||||
color: '#2b90d9'
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
display: 'inline-block',
|
||||
marginRight: '5px'
|
||||
};
|
||||
|
||||
const ColumnBackButton = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleClick () {
|
||||
this.context.router.goBack();
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div onClick={this.handleClick} style={outerStyle}>
|
||||
<i className='fa fa-fw fa-chevron-left' style={iconStyle} />
|
||||
Back
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default ColumnBackButton;
|
@@ -0,0 +1,30 @@
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
|
||||
const DropdownMenu = ({ icon, items, size }) => {
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
|
||||
<i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent style={{ lineHeight: '18px' }}>
|
||||
<ul>
|
||||
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
}
|
||||
}}>{text}</a></li>)}
|
||||
</ul>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
DropdownMenu.propTypes = {
|
||||
icon: React.PropTypes.string.isRequired,
|
||||
items: React.PropTypes.array.isRequired,
|
||||
size: React.PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
@@ -28,16 +28,21 @@ const IconButton = React.createClass({
|
||||
render () {
|
||||
const style = {
|
||||
display: 'inline-block',
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
background: 'transparent',
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size}px`,
|
||||
lineHeight: `${this.props.size}px`
|
||||
lineHeight: `${this.props.size}px`,
|
||||
cursor: 'pointer',
|
||||
...this.props.style
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
|
||||
<i className={`fa fa-fw fa-${this.props.icon}`}></i>
|
||||
</a>
|
||||
<button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
|
||||
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
53
app/assets/javascripts/components/components/lightbox.jsx
Normal file
53
app/assets/javascripts/components/components/lightbox.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import IconButton from './icon_button';
|
||||
|
||||
const overlayStyle = {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
zIndex: '9999'
|
||||
};
|
||||
|
||||
const dialogStyle = {
|
||||
color: '#282c37',
|
||||
background: '#d9e1e8',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 0 15px rgba(0, 0, 0, 0.4)',
|
||||
padding: '10px',
|
||||
margin: 'auto',
|
||||
position: 'relative'
|
||||
};
|
||||
|
||||
const closeStyle = {
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px'
|
||||
};
|
||||
|
||||
const Lightbox = ({ isVisible, onOverlayClicked, onCloseClicked, children }) => {
|
||||
if (!isVisible) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='lightbox' style={overlayStyle} onClick={onOverlayClicked}>
|
||||
<div style={dialogStyle}>
|
||||
<IconButton title='Close' icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Lightbox.propTypes = {
|
||||
isVisible: React.PropTypes.bool,
|
||||
onOverlayClicked: React.PropTypes.func,
|
||||
onCloseClicked: React.PropTypes.func
|
||||
};
|
||||
|
||||
export default Lightbox;
|
@@ -0,0 +1,13 @@
|
||||
const LoadingIndicator = () => {
|
||||
const style = {
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: '#616b86',
|
||||
paddingTop: '120px'
|
||||
};
|
||||
|
||||
return <div style={style}>Loading...</div>;
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
@@ -5,11 +5,21 @@ const MediaGallery = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
height: React.PropTypes.number.isRequired
|
||||
height: React.PropTypes.number.isRequired,
|
||||
onOpenMedia: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleClick (url, e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.props.onOpenMedia(url);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
},
|
||||
|
||||
render () {
|
||||
var children = this.props.media.take(4);
|
||||
var size = children.size;
|
||||
@@ -25,7 +35,7 @@ const MediaGallery = React.createClass({
|
||||
if (size === 1) {
|
||||
width = 100;
|
||||
}
|
||||
|
||||
|
||||
if (size === 4 || (size === 3 && i > 0)) {
|
||||
height = 50;
|
||||
}
|
||||
@@ -64,7 +74,11 @@ const MediaGallery = React.createClass({
|
||||
}
|
||||
}
|
||||
|
||||
return <a key={attachment.get('id')} href={attachment.get('url')} target='_blank' style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', textDecoration: 'none', border: 'none', display: 'block', width: `${width}%`, height: `${height}%`, background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover', cursor: 'zoom-in' }} />;
|
||||
return (
|
||||
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
|
||||
<a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
|
@@ -21,35 +21,28 @@ moment.updateLocale('en', {
|
||||
|
||||
const RelativeTimestamp = React.createClass({
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
text: ''
|
||||
};
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
timestamp: React.PropTypes.string.isRequired
|
||||
timestamp: React.PropTypes.string.isRequired,
|
||||
now: React.PropTypes.any
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this._updateMomentText();
|
||||
this.interval = setInterval(this._updateMomentText, 6000);
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
clearInterval(this.interval);
|
||||
},
|
||||
|
||||
_updateMomentText () {
|
||||
this.setState({ text: moment(this.props.timestamp).fromNow() });
|
||||
},
|
||||
|
||||
render () {
|
||||
const timestamp = moment(this.props.timestamp);
|
||||
const now = this.props.now;
|
||||
|
||||
let string = '';
|
||||
|
||||
if (timestamp.isAfter(now)) {
|
||||
string = 'Just now';
|
||||
} else {
|
||||
string = timestamp.from(now);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{this.state.text}
|
||||
{string}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@@ -15,13 +15,15 @@ const Status = React.createClass({
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
wrapped: React.PropTypes.bool,
|
||||
onReply: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func,
|
||||
me: React.PropTypes.number
|
||||
onOpenMedia: React.PropTypes.func,
|
||||
me: React.PropTypes.number,
|
||||
now: React.PropTypes.any
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -41,16 +43,25 @@ const Status = React.createClass({
|
||||
},
|
||||
|
||||
render () {
|
||||
var media = '';
|
||||
let media = '';
|
||||
const { status, now, ...other } = this.props;
|
||||
|
||||
var { status, ...other } = this.props;
|
||||
if (status === null) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
let displayName = status.getIn(['account', 'display_name']);
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = status.getIn(['account', 'username']);
|
||||
}
|
||||
|
||||
if (status.get('reblog') !== null) {
|
||||
return (
|
||||
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
|
||||
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div>
|
||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{status.getIn(['account', 'display_name'])}</strong></a> reblogged
|
||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{displayName}</strong></a> reblogged
|
||||
</div>
|
||||
|
||||
<Status {...other} wrapped={true} status={status.get('reblog')} />
|
||||
@@ -62,7 +73,7 @@ const Status = React.createClass({
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
|
||||
} else {
|
||||
media = <MediaGallery media={status.get('media_attachments')} height={110} />;
|
||||
media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +81,7 @@ const Status = React.createClass({
|
||||
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
|
||||
<div style={{ fontSize: '15px' }}>
|
||||
<div style={{ float: 'right', fontSize: '14px' }}>
|
||||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} now={now} /></a>
|
||||
</div>
|
||||
|
||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
|
||||
const StatusActionBar = React.createClass({
|
||||
propTypes: {
|
||||
@@ -9,7 +9,8 @@ const StatusActionBar = React.createClass({
|
||||
onReply: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func
|
||||
onDelete: React.PropTypes.func,
|
||||
onMention: React.PropTypes.func
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -26,23 +27,22 @@ const StatusActionBar = React.createClass({
|
||||
this.props.onReblog(this.props.status);
|
||||
},
|
||||
|
||||
handleDeleteClick(e) {
|
||||
e.preventDefault();
|
||||
handleDeleteClick () {
|
||||
this.props.onDelete(this.props.status);
|
||||
},
|
||||
|
||||
handleMentionClick () {
|
||||
this.props.onMention(this.props.status.get('account'));
|
||||
},
|
||||
|
||||
render () {
|
||||
const { status, me } = this.props;
|
||||
let menu = '';
|
||||
let menu = [];
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
menu = (
|
||||
<ul>
|
||||
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
|
||||
</ul>
|
||||
);
|
||||
menu.push({ text: 'Delete', action: this.handleDeleteClick });
|
||||
} else {
|
||||
menu = <ul />;
|
||||
menu.push({ text: 'Mention', action: this.handleMentionClick });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,13 +52,7 @@ const StatusActionBar = React.createClass({
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||
<Dropdown>
|
||||
<DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}>
|
||||
<i className='fa fa-fw fa-ellipsis-h' />
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent>{menu}</DropdownContent>
|
||||
</Dropdown>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,5 +1,10 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import emojione from 'emojione';
|
||||
|
||||
emojione.imageType = 'png';
|
||||
emojione.sprites = false;
|
||||
emojione.imagePathPNG = '/emoji/';
|
||||
|
||||
const StatusContent = React.createClass({
|
||||
|
||||
@@ -22,12 +27,15 @@ const StatusContent = React.createClass({
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention));
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
link.addEventListener('click', this.onNormalClick.bind(this));
|
||||
}
|
||||
|
||||
link.addEventListener('click', this.onNormalClick, false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,8 +44,15 @@ const StatusContent = React.createClass({
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
},
|
||||
|
||||
e.stopPropagation();
|
||||
onHashtagClick (hashtag, e) {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/statuses/tag/${hashtag}`);
|
||||
}
|
||||
},
|
||||
|
||||
onNormalClick (e) {
|
||||
@@ -45,7 +60,7 @@ const StatusContent = React.createClass({
|
||||
},
|
||||
|
||||
render () {
|
||||
const content = { __html: this.props.status.get('content') };
|
||||
const content = { __html: emojione.unicodeToImage(this.props.status.get('content')) };
|
||||
return <div className='status__content' dangerouslySetInnerHTML={content} />;
|
||||
},
|
||||
|
||||
|
@@ -1,41 +1,82 @@
|
||||
import Status from './status';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import Status from './status';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import StatusContainer from '../containers/status_container';
|
||||
import moment from 'moment';
|
||||
|
||||
const StatusList = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
statuses: ImmutablePropTypes.list.isRequired,
|
||||
onReply: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
onScrollToBottom: React.PropTypes.func,
|
||||
me: React.PropTypes.number
|
||||
trackScroll: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
trackScroll: true
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
now: moment()
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentDidMount () {
|
||||
this._interval = setInterval(() => this.setState({ now: moment() }), 60000);
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
clearInterval(this._interval);
|
||||
},
|
||||
|
||||
handleScroll (e) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
this.props.onScrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { statuses, onScrollToBottom, ...other } = this.props;
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
|
||||
const node = ReactDOM.findDOMNode(this);
|
||||
|
||||
return (
|
||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
|
||||
if (node.scrollTop > 0) {
|
||||
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { statusIds, onScrollToBottom, trackScroll } = this.props;
|
||||
|
||||
const scrollableArea = (
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
<div>
|
||||
{statuses.map((status) => {
|
||||
return <Status key={status.get('id')} {...other} status={status} />;
|
||||
{statusIds.map((statusId) => {
|
||||
return <StatusContainer key={statusId} id={statusId} now={this.state.now} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (trackScroll) {
|
||||
return (
|
||||
<ScrollContainer scrollKey='status-list'>
|
||||
{scrollableArea}
|
||||
</ScrollContainer>
|
||||
);
|
||||
} else {
|
||||
return scrollableArea;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
@@ -2,6 +2,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
|
||||
const videoStyle = {
|
||||
position: 'relative',
|
||||
zIndex: '1',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
|
||||
const muteStyle = {
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
opacity: '0.8',
|
||||
zIndex: '5'
|
||||
};
|
||||
|
||||
const VideoPlayer = React.createClass({
|
||||
propTypes: {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
@@ -28,11 +46,23 @@ const VideoPlayer = React.createClass({
|
||||
this.setState({ muted: !this.state.muted });
|
||||
},
|
||||
|
||||
handleVideoClick (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
const node = ReactDOM.findDOMNode(this).querySelector('video');
|
||||
|
||||
if (node.paused) {
|
||||
node.play();
|
||||
} else {
|
||||
node.pause();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: '10px', left: '10px', opacity: '0.8' }}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
|
||||
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
|
||||
<div style={muteStyle}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
|
||||
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,20 +1,36 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from '../store/configureStore';
|
||||
import {
|
||||
refreshTimelineSuccess,
|
||||
updateTimeline,
|
||||
deleteFromTimelines,
|
||||
refreshTimeline
|
||||
} from '../actions/timelines';
|
||||
import { setAccessToken } from '../actions/meta';
|
||||
import { setAccountSelf } from '../actions/accounts';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { Router, Route, hashHistory } from 'react-router';
|
||||
import Account from '../features/account';
|
||||
import Settings from '../features/settings';
|
||||
import Status from '../features/status';
|
||||
import Subscriptions from '../features/subscriptions';
|
||||
import UI from '../features/ui';
|
||||
} from '../actions/timelines';
|
||||
import { setAccessToken } from '../actions/meta';
|
||||
import { setAccountSelf } from '../actions/accounts';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import {
|
||||
applyRouterMiddleware,
|
||||
Router,
|
||||
Route,
|
||||
hashHistory,
|
||||
IndexRoute
|
||||
} from 'react-router';
|
||||
import { useScroll } from 'react-router-scroll';
|
||||
import UI from '../features/ui';
|
||||
import Account from '../features/account';
|
||||
import Status from '../features/status';
|
||||
import GettingStarted from '../features/getting_started';
|
||||
import PublicTimeline from '../features/public_timeline';
|
||||
import AccountTimeline from '../features/account_timeline';
|
||||
import HomeTimeline from '../features/home_timeline';
|
||||
import MentionsTimeline from '../features/mentions_timeline';
|
||||
import Compose from '../features/compose';
|
||||
import Followers from '../features/followers';
|
||||
import Following from '../features/following';
|
||||
import Reblogs from '../features/reblogs';
|
||||
import Favourites from '../features/favourites';
|
||||
import HashtagTimeline from '../features/hashtag_timeline';
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
@@ -32,21 +48,8 @@ const Mastodon = React.createClass({
|
||||
store.dispatch(setAccessToken(this.props.token));
|
||||
store.dispatch(setAccountSelf(JSON.parse(this.props.account)));
|
||||
|
||||
for (var timelineType in this.props.timelines) {
|
||||
if (this.props.timelines.hasOwnProperty(timelineType)) {
|
||||
store.dispatch(refreshTimelineSuccess(timelineType, JSON.parse(this.props.timelines[timelineType])));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof App !== 'undefined') {
|
||||
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
|
||||
connected () {
|
||||
|
||||
},
|
||||
|
||||
disconnected () {
|
||||
|
||||
},
|
||||
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
||||
|
||||
received (data) {
|
||||
switch(data.type) {
|
||||
@@ -56,24 +59,44 @@ const Mastodon = React.createClass({
|
||||
return store.dispatch(deleteFromTimelines(data.id));
|
||||
case 'merge':
|
||||
case 'unmerge':
|
||||
return store.dispatch(refreshTimeline('home'));
|
||||
return store.dispatch(refreshTimeline('home', true));
|
||||
case 'block':
|
||||
return store.dispatch(refreshTimeline('mentions'));
|
||||
return store.dispatch(refreshTimeline('mentions', true));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
if (typeof this.subscription !== 'undefined') {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Router history={hashHistory}>
|
||||
<Router history={hashHistory} render={applyRouterMiddleware(useScroll())}>
|
||||
<Route path='/' component={UI}>
|
||||
<Route path='/settings' component={Settings} />
|
||||
<Route path='/subscriptions' component={Subscriptions} />
|
||||
<IndexRoute component={GettingStarted} />
|
||||
<Route path='/statuses/new' component={Compose} />
|
||||
|
||||
<Route path='/statuses/home' component={HomeTimeline} />
|
||||
<Route path='/statuses/mentions' component={MentionsTimeline} />
|
||||
<Route path='/statuses/all' component={PublicTimeline} />
|
||||
<Route path='/statuses/tag/:id' component={HashtagTimeline} />
|
||||
|
||||
<Route path='/statuses/:statusId' component={Status} />
|
||||
<Route path='/accounts/:accountId' component={Account} />
|
||||
<Route path='/statuses/:statusId/reblogs' component={Reblogs} />
|
||||
<Route path='/statuses/:statusId/favourites' component={Favourites} />
|
||||
|
||||
<Route path='/accounts/:accountId' component={Account}>
|
||||
<IndexRoute component={AccountTimeline} />
|
||||
<Route path='/accounts/:accountId/followers' component={Followers} />
|
||||
<Route path='/accounts/:accountId/following' component={Following} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Router>
|
||||
</Provider>
|
||||
|
@@ -0,0 +1,102 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose
|
||||
} from '../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite
|
||||
} from '../actions/interactions';
|
||||
import { deleteStatus } from '../actions/statuses';
|
||||
import { openMedia } from '../actions/modal';
|
||||
import { createSelector } from 'reselect'
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statusBase: state.getIn(['statuses', props.id]),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
const makeMapStateToPropsInner = () => {
|
||||
const getStatus = (() => {
|
||||
return createSelector(
|
||||
[
|
||||
(_, base) => base,
|
||||
(state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
|
||||
(state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
|
||||
],
|
||||
|
||||
(base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
|
||||
);
|
||||
})();
|
||||
|
||||
const mapStateToProps = (state, { statusBase }) => ({
|
||||
status: getStatus(state, statusBase)
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const makeMapStateToPropsLast = () => {
|
||||
const getStatus = (() => {
|
||||
return createSelector(
|
||||
[
|
||||
(_, status) => status,
|
||||
(state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
|
||||
],
|
||||
|
||||
(status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
|
||||
);
|
||||
})();
|
||||
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
status: getStatus(state, status)
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
onReply (status) {
|
||||
dispatch(replyCompose(status));
|
||||
},
|
||||
|
||||
onReblog (status) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
dispatch(reblog(status));
|
||||
}
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
onDelete (status) {
|
||||
dispatch(deleteStatus(status.get('id')));
|
||||
},
|
||||
|
||||
onMention (account) {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
|
||||
onOpenMedia (url) {
|
||||
dispatch(openMedia(url));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(
|
||||
connect(makeMapStateToPropsInner)(
|
||||
connect(makeMapStateToPropsLast)(Status)
|
||||
)
|
||||
);
|
@@ -1,6 +1,27 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Button from '../../../components/button';
|
||||
import DropdownMenu from '../../../components/dropdown_menu';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const outerStyle = {
|
||||
borderTop: '1px solid #363c4b',
|
||||
borderBottom: '1px solid #363c4b',
|
||||
lineHeight: '36px',
|
||||
overflow: 'hidden',
|
||||
flex: '0 0 auto',
|
||||
display: 'flex'
|
||||
};
|
||||
|
||||
const outerDropdownStyle = {
|
||||
padding: '10px',
|
||||
flex: '1 1 auto'
|
||||
};
|
||||
|
||||
const outerLinksStyle = {
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
lineHeight: '18px'
|
||||
};
|
||||
|
||||
const ActionBar = React.createClass({
|
||||
|
||||
@@ -8,7 +29,8 @@ const ActionBar = React.createClass({
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired,
|
||||
onFollow: React.PropTypes.func.isRequired,
|
||||
onBlock: React.PropTypes.func.isRequired
|
||||
onBlock: React.PropTypes.func.isRequired,
|
||||
onMention: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -16,47 +38,44 @@ const ActionBar = React.createClass({
|
||||
render () {
|
||||
const { account, me } = this.props;
|
||||
|
||||
let infoText = '';
|
||||
let follow = '';
|
||||
let buttonText = '';
|
||||
let block = '';
|
||||
let disabled = false;
|
||||
let menu = [];
|
||||
|
||||
menu.push({ text: 'Mention', action: this.props.onMention });
|
||||
|
||||
if (account.get('id') === me) {
|
||||
buttonText = 'This is you!';
|
||||
disabled = true;
|
||||
menu.push({ text: 'Edit profile', href: '/settings/profile' });
|
||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||
menu.push({ text: 'Unblock', action: this.props.onBlock });
|
||||
} else if (account.getIn(['relationship', 'following'])) {
|
||||
menu.push({ text: 'Unfollow', action: this.props.onFollow });
|
||||
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||
} else {
|
||||
let blockText = '';
|
||||
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
buttonText = 'Blocked';
|
||||
disabled = true;
|
||||
blockText = 'Unblock';
|
||||
} else {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
buttonText = 'Unfollow';
|
||||
} else {
|
||||
buttonText = 'Follow';
|
||||
}
|
||||
|
||||
if (account.getIn(['relationship', 'followed_by'])) {
|
||||
infoText = 'Follows you!';
|
||||
}
|
||||
|
||||
blockText = 'Block';
|
||||
}
|
||||
|
||||
block = <Button text={blockText} onClick={this.props.onBlock} />;
|
||||
}
|
||||
|
||||
if (!account.getIn(['relationship', 'blocking'])) {
|
||||
follow = <Button text={buttonText} onClick={this.props.onFollow} disabled={disabled} />;
|
||||
menu.push({ text: 'Follow', action: this.props.onFollow });
|
||||
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}>
|
||||
{follow} {block}
|
||||
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span>
|
||||
<div style={outerStyle}>
|
||||
<div style={outerDropdownStyle}>
|
||||
<DropdownMenu items={menu} icon='bars' size={24} />
|
||||
</div>
|
||||
|
||||
<div style={outerLinksStyle}>
|
||||
<Link to={`/accounts/${account.get('id')}`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
|
||||
</Link>
|
||||
|
||||
<Link to={`/accounts/${account.get('id')}/following`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px 5px' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
|
||||
</Link>
|
||||
|
||||
<Link to={`/accounts/${account.get('id')}/followers`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', padding: '10px 5px', borderLeft: '1px solid #363c4b' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@@ -4,24 +4,43 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
const Header = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
const { account, me } = this.props;
|
||||
|
||||
let displayName = account.get('display_name');
|
||||
let info = '';
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = account.get('username');
|
||||
}
|
||||
|
||||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
||||
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
|
||||
}
|
||||
|
||||
const content = { __html: account.get('note') };
|
||||
|
||||
return (
|
||||
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover' }}>
|
||||
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
|
||||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
||||
</div>
|
||||
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
|
||||
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '20px 10px' }}>
|
||||
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
|
||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
||||
</div>
|
||||
|
||||
<span style={{ color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500', display: 'block' }}>{account.get('display_name')}</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
|
||||
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
|
||||
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
|
||||
</a>
|
||||
|
||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')}</span>
|
||||
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||
|
||||
{info}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -10,53 +10,46 @@ import {
|
||||
fetchAccountTimeline,
|
||||
expandAccountTimeline
|
||||
} from '../../actions/accounts';
|
||||
import { deleteStatus } from '../../actions/statuses';
|
||||
import { replyCompose } from '../../actions/compose';
|
||||
import {
|
||||
favourite,
|
||||
reblog,
|
||||
unreblog,
|
||||
unfavourite
|
||||
} from '../../actions/interactions';
|
||||
import { mentionCompose } from '../../actions/compose';
|
||||
import Header from './components/header';
|
||||
import {
|
||||
selectStatus,
|
||||
selectAccount
|
||||
} from '../../reducers/timelines';
|
||||
import StatusList from '../../components/status_list';
|
||||
import Immutable from 'immutable';
|
||||
getAccountTimeline,
|
||||
makeGetAccount
|
||||
} from '../../selectors';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import ActionBar from './components/action_bar';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
|
||||
function selectStatuses(state, accountId) {
|
||||
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null);
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, Number(props.params.accountId)),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: selectAccount(state, Number(props.params.accountId)),
|
||||
statuses: selectStatuses(state, Number(props.params.accountId)),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
});
|
||||
|
||||
const Account = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
account: ImmutablePropTypes.map,
|
||||
statuses: ImmutablePropTypes.list
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -76,50 +69,33 @@ const Account = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
handleReply (status) {
|
||||
this.props.dispatch(replyCompose(status));
|
||||
},
|
||||
|
||||
handleReblog (status) {
|
||||
if (status.get('reblogged')) {
|
||||
this.props.dispatch(unreblog(status));
|
||||
} else {
|
||||
this.props.dispatch(reblog(status));
|
||||
}
|
||||
},
|
||||
|
||||
handleFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
this.props.dispatch(unfavourite(status));
|
||||
} else {
|
||||
this.props.dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
handleDelete (status) {
|
||||
this.props.dispatch(deleteStatus(status.get('id')));
|
||||
},
|
||||
|
||||
handleScrollToBottom () {
|
||||
this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
|
||||
handleMention () {
|
||||
this.props.dispatch(mentionCompose(this.props.account));
|
||||
},
|
||||
|
||||
render () {
|
||||
const { account, statuses, me } = this.props;
|
||||
const { account, me } = this.props;
|
||||
|
||||
if (account === null) {
|
||||
return <div>Loading {this.props.params.accountId}...</div>;
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
|
||||
<Header account={account} />
|
||||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
|
||||
<StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
|
||||
</div>
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<Header account={account} me={me} />
|
||||
|
||||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} />
|
||||
|
||||
{this.props.children}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Account);
|
||||
export default connect(makeMapStateToProps)(Account);
|
||||
|
@@ -0,0 +1,52 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import {
|
||||
fetchAccountTimeline,
|
||||
expandAccountTimeline
|
||||
} from '../../actions/accounts';
|
||||
import StatusList from '../../components/status_list';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
const AccountTimeline = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
handleScrollToBottom () {
|
||||
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
render () {
|
||||
const { statusIds, me } = this.props;
|
||||
|
||||
if (!statusIds) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(AccountTimeline);
|
@@ -0,0 +1,85 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import AccountContainer from '../../followers/containers/account_container';
|
||||
|
||||
const outerStyle = {
|
||||
position: 'relative'
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'block',
|
||||
padding: '10px',
|
||||
color: '#9baec8',
|
||||
background: '#454b5e',
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
const nextStyle = {
|
||||
display: 'inline-block',
|
||||
float: 'right',
|
||||
fontWeight: '400',
|
||||
color: '#2b90d9'
|
||||
};
|
||||
|
||||
const SuggestionsBox = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
perWindow: React.PropTypes.number
|
||||
},
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
index: 0
|
||||
};
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
perWindow: 2
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleNextClick (e) {
|
||||
e.preventDefault();
|
||||
|
||||
let newIndex = this.state.index + 1;
|
||||
|
||||
if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) {
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
this.setState({ index: newIndex });
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds, perWindow } = this.props;
|
||||
|
||||
if (!accountIds || accountIds.size === 0) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let nextLink = '';
|
||||
|
||||
if (accountIds.size > perWindow) {
|
||||
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Refresh</a>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<strong style={headerStyle}>
|
||||
Who to follow {nextLink}
|
||||
</strong>
|
||||
|
||||
{accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default SuggestionsBox;
|
@@ -0,0 +1,8 @@
|
||||
import { connect } from 'react-redux';
|
||||
import SuggestionsBox from '../components/suggestions_box';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
accountIds: state.getIn(['user_lists', 'suggestions'])
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(SuggestionsBox);
|
40
app/assets/javascripts/components/features/compose/index.jsx
Normal file
40
app/assets/javascripts/components/features/compose/index.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Drawer from '../ui/components/drawer';
|
||||
import ComposeFormContainer from '../ui/containers/compose_form_container';
|
||||
import FollowFormContainer from '../ui/containers/follow_form_container';
|
||||
import UploadFormContainer from '../ui/containers/upload_form_container';
|
||||
import NavigationContainer from '../ui/containers/navigation_container';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import SuggestionsContainer from './containers/suggestions_container';
|
||||
import { fetchSuggestions } from '../../actions/suggestions';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const Compose = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchSuggestions());
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Drawer>
|
||||
<div style={{ flex: '1 1 auto' }}>
|
||||
<NavigationContainer />
|
||||
<ComposeFormContainer />
|
||||
<UploadFormContainer />
|
||||
</div>
|
||||
|
||||
<SuggestionsContainer />
|
||||
<FollowFormContainer />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect()(Compose);
|
@@ -0,0 +1,61 @@
|
||||
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 { fetchFavourites } from '../../actions/interactions';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from '../followers/containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
|
||||
});
|
||||
|
||||
const Favourites = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<ScrollContainer scrollKey='favourites'>
|
||||
<div className='scrollable'>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Favourites);
|
@@ -0,0 +1,94 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { Link } from 'react-router';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
|
||||
const outerStyle = {
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #363c4b'
|
||||
};
|
||||
|
||||
const itemStyle = {
|
||||
flex: '1 1 auto',
|
||||
display: 'block',
|
||||
color: '#9baec8',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const noteStyle = {
|
||||
paddingTop: '5px',
|
||||
fontSize: '12px',
|
||||
color: '#616b86'
|
||||
};
|
||||
|
||||
const buttonsStyle = {
|
||||
padding: '10px'
|
||||
};
|
||||
|
||||
const Account = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired,
|
||||
onFollow: React.PropTypes.func.isRequired,
|
||||
withNote: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
withNote: true
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleFollow () {
|
||||
this.props.onFollow(this.props.account);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { account, me, withNote } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let note, buttons;
|
||||
|
||||
if (account.get('note').length > 0 && withNote) {
|
||||
note = <div style={noteStyle}>{account.get('note')}</div>;
|
||||
}
|
||||
|
||||
if (account.get('id') !== me) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
|
||||
buttons = (
|
||||
<div style={buttonsStyle}>
|
||||
<IconButton icon={following ? 'user-times' : 'user-plus'} title='Follow' onClick={this.handleFollow} active={following} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Link key={account.get('id')} style={itemStyle} className='account__display-name' to={`/accounts/${account.get('id')}`}>
|
||||
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
{buttons}
|
||||
</div>
|
||||
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default Account;
|
@@ -0,0 +1,30 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
import Account from '../components/account';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount
|
||||
} from '../../../actions/accounts';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Account);
|
@@ -0,0 +1,51 @@
|
||||
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 { fetchFollowers } from '../../actions/accounts';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from './containers/account_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId)])
|
||||
});
|
||||
|
||||
const Followers = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollContainer scrollKey='followers'>
|
||||
<div className='scrollable'>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Followers);
|
@@ -0,0 +1,51 @@
|
||||
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 { fetchFollowing } from '../../actions/accounts';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from '../followers/containers/account_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId)])
|
||||
});
|
||||
|
||||
const Following = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollContainer scrollKey='following'>
|
||||
<div className='scrollable'>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Following);
|
@@ -0,0 +1,18 @@
|
||||
import Column from '../ui/components/column';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const GettingStarted = () => {
|
||||
return (
|
||||
<Column>
|
||||
<div className='static-content'>
|
||||
<h1>Getting started</h1>
|
||||
<p>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 in the bottom of the sidebar.</p>
|
||||
<p>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>The developer of this project can be followed as Gargron@mastodon.social</p>
|
||||
<p>Also <Link to='/statuses/all' style={{ color: '#2b90d9', textDecoration: 'none' }}>check out the public timeline for a start</Link>!</p>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GettingStarted;
|
@@ -0,0 +1,78 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../ui/components/column';
|
||||
import {
|
||||
refreshTimeline,
|
||||
updateTimeline,
|
||||
deleteFromTimelines
|
||||
} from '../../actions/timelines';
|
||||
|
||||
const HashtagTimeline = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
_subscribe (dispatch, id) {
|
||||
if (typeof App !== 'undefined') {
|
||||
this.subscription = App.cable.subscriptions.create({
|
||||
channel: 'HashtagChannel',
|
||||
tag: id
|
||||
}, {
|
||||
|
||||
received (data) {
|
||||
switch(data.type) {
|
||||
case 'update':
|
||||
return dispatch(updateTimeline('tag', JSON.parse(data.message)));
|
||||
case 'delete':
|
||||
return dispatch(deleteFromTimelines(data.id));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_unsubscribe () {
|
||||
if (typeof this.subscription !== 'undefined') {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillMount () {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = this.props.params;
|
||||
|
||||
dispatch(refreshTimeline('tag', true, id));
|
||||
this._subscribe(dispatch, id);
|
||||
},
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.id !== this.props.params.id) {
|
||||
this.props.dispatch(refreshTimeline('tag', true, nextProps.params.id));
|
||||
this._unsubscribe();
|
||||
this._subscribe(this.props.dispatch, nextProps.params.id);
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
this._unsubscribe();
|
||||
},
|
||||
|
||||
render () {
|
||||
const { id } = this.props.params;
|
||||
|
||||
return (
|
||||
<Column icon='hashtag' heading={id}>
|
||||
<StatusListContainer type='tag' id={id} />
|
||||
</Column>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect()(HashtagTimeline);
|
@@ -0,0 +1,29 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../ui/components/column';
|
||||
import { refreshTimeline } from '../../actions/timelines';
|
||||
|
||||
const HomeTimeline = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(refreshTimeline('home'));
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Column icon='home' heading='Home'>
|
||||
<StatusListContainer {...this.props} type='home' />
|
||||
</Column>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect()(HomeTimeline);
|
@@ -0,0 +1,29 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../ui/components/column';
|
||||
import { refreshTimeline } from '../../actions/timelines';
|
||||
|
||||
const MentionsTimeline = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(refreshTimeline('mentions'));
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Column icon='at' heading='Mentions'>
|
||||
<StatusListContainer {...this.props} type='mentions' />
|
||||
</Column>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect()(MentionsTimeline);
|
@@ -0,0 +1,56 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../ui/components/column';
|
||||
import {
|
||||
refreshTimeline,
|
||||
updateTimeline,
|
||||
deleteFromTimelines
|
||||
} from '../../actions/timelines';
|
||||
|
||||
const PublicTimeline = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(refreshTimeline('public'));
|
||||
|
||||
if (typeof App !== 'undefined') {
|
||||
this.subscription = App.cable.subscriptions.create('PublicChannel', {
|
||||
|
||||
received (data) {
|
||||
switch(data.type) {
|
||||
case 'update':
|
||||
return dispatch(updateTimeline('public', JSON.parse(data.message)));
|
||||
case 'delete':
|
||||
return dispatch(deleteFromTimelines(data.id));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
if (typeof this.subscription !== 'undefined') {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Column icon='globe' heading='Public'>
|
||||
<StatusListContainer type='public' />
|
||||
</Column>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect()(PublicTimeline);
|
61
app/assets/javascripts/components/features/reblogs/index.jsx
Normal file
61
app/assets/javascripts/components/features/reblogs/index.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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 { fetchReblogs } from '../../actions/interactions';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from '../followers/containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)])
|
||||
});
|
||||
|
||||
const Reblogs = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<ScrollContainer scrollKey='reblogs'>
|
||||
<div className='scrollable'>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Reblogs);
|
@@ -1,28 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
|
||||
});
|
||||
|
||||
const Settings = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
//
|
||||
},
|
||||
|
||||
render () {
|
||||
return <div>Settings</div>;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Settings);
|
@@ -1,26 +1,39 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenu from '../../../components/dropdown_menu';
|
||||
|
||||
const ActionBar = React.createClass({
|
||||
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: React.PropTypes.func.isRequired,
|
||||
onReblog: React.PropTypes.func.isRequired,
|
||||
onFavourite: React.PropTypes.func.isRequired
|
||||
onFavourite: React.PropTypes.func.isRequired,
|
||||
onDelete: React.PropTypes.func.isRequired,
|
||||
onMention: React.PropTypes.func.isRequired,
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { status } = this.props;
|
||||
const { status, me } = this.props;
|
||||
|
||||
let menu = [];
|
||||
|
||||
if (me === status.getIn(['account', 'id'])) {
|
||||
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
|
||||
} else {
|
||||
menu.push({ text: 'Mention', action: () => this.props.onMention(status.get('account')) });
|
||||
}
|
||||
|
||||
return (
|
||||
<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='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
|
||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
|
||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
|
||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import StatusContent from '../../../components/status_content';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import VideoPlayer from '../../../components/video_player';
|
||||
import moment from 'moment';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const DetailedStatus = React.createClass({
|
||||
|
||||
@@ -14,7 +15,8 @@ const DetailedStatus = React.createClass({
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onOpenMedia: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -36,7 +38,7 @@ const DetailedStatus = React.createClass({
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
|
||||
} else {
|
||||
media = <MediaGallery media={status.get('media_attachments')} height={300} />;
|
||||
media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ const DetailedStatus = React.createClass({
|
||||
{media}
|
||||
|
||||
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
|
||||
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span> · <i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span>
|
||||
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</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' }}>{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' }}>{status.get('favourites_count')}</span></Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -4,30 +4,47 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { fetchStatus } from '../../actions/statuses';
|
||||
import Immutable from 'immutable';
|
||||
import EmbeddedStatus from '../../components/status';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import DetailedStatus from './components/detailed_status';
|
||||
import ActionBar from './components/action_bar';
|
||||
import Column from '../ui/components/column';
|
||||
import { favourite, reblog } from '../../actions/interactions';
|
||||
import { replyCompose } from '../../actions/compose';
|
||||
import { selectStatus } from '../../reducers/timelines';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose
|
||||
} from '../../actions/compose';
|
||||
import { deleteStatus } from '../../actions/statuses';
|
||||
import {
|
||||
makeGetStatus,
|
||||
getStatusAncestors,
|
||||
getStatusDescendants
|
||||
} from '../../selectors';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import StatusContainer from '../../containers/status_container';
|
||||
import { openMedia } from '../../actions/modal';
|
||||
|
||||
function selectStatuses(state, ids) {
|
||||
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null);
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, Number(props.params.statusId)),
|
||||
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
|
||||
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: selectStatus(state, Number(props.params.statusId)),
|
||||
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.OrderedSet())),
|
||||
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.OrderedSet()))
|
||||
});
|
||||
|
||||
const Status = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
ancestors: ImmutablePropTypes.orderedSet.isRequired,
|
||||
descendants: ImmutablePropTypes.orderedSet.isRequired
|
||||
ancestorsIds: ImmutablePropTypes.list,
|
||||
descendantsIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -54,31 +71,62 @@ const Status = React.createClass({
|
||||
this.props.dispatch(reblog(status));
|
||||
},
|
||||
|
||||
handleDeleteClick (status) {
|
||||
this.props.dispatch(deleteStatus(status.get('id')));
|
||||
},
|
||||
|
||||
handleMentionClick (account) {
|
||||
this.props.dispatch(mentionCompose(account));
|
||||
},
|
||||
|
||||
handleOpenMedia (url) {
|
||||
this.props.dispatch(openMedia(url));
|
||||
},
|
||||
|
||||
renderChildren (list) {
|
||||
return list.map(s => <EmbeddedStatus status={s} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />);
|
||||
return list.map(id => <StatusContainer key={id} id={id} />);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { status, ancestors, descendants } = this.props;
|
||||
let ancestors, descendants;
|
||||
const { status, ancestorsIds, descendantsIds, me } = this.props;
|
||||
|
||||
if (status === null) {
|
||||
return <div>Loading {this.props.params.statusId}...</div>;
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const account = status.get('account');
|
||||
|
||||
if (ancestorsIds && ancestorsIds.size > 0) {
|
||||
ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
|
||||
}
|
||||
|
||||
if (descendantsIds && descendantsIds.size > 0) {
|
||||
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
||||
<div>{this.renderChildren(ancestors)}</div>
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<DetailedStatus status={status} />
|
||||
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className='scrollable'>
|
||||
{ancestors}
|
||||
|
||||
<div>{this.renderChildren(descendants)}</div>
|
||||
</div>
|
||||
<DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} />
|
||||
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} />
|
||||
|
||||
{descendants}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Status);
|
||||
export default connect(makeMapStateToProps)(Status);
|
||||
|
@@ -1,28 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
|
||||
});
|
||||
|
||||
const Subscriptions = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
//
|
||||
},
|
||||
|
||||
render () {
|
||||
return <div>Subscriptions</div>;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Subscriptions);
|
@@ -18,7 +18,7 @@ const scrollTop = (node) => {
|
||||
return;
|
||||
}
|
||||
|
||||
node.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration));
|
||||
node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,12 @@ const scrollTop = (node) => {
|
||||
};
|
||||
};
|
||||
|
||||
const style = {
|
||||
boxSizing: 'border-box',
|
||||
background: '#282c37',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const Column = React.createClass({
|
||||
|
||||
@@ -50,10 +56,6 @@ const Column = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll () {
|
||||
// todo
|
||||
},
|
||||
|
||||
render () {
|
||||
let header = '';
|
||||
|
||||
@@ -61,10 +63,8 @@ const Column = React.createClass({
|
||||
header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />;
|
||||
}
|
||||
|
||||
const style = { width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', marginBottom: '0', display: 'flex', flexDirection: 'column' };
|
||||
|
||||
return (
|
||||
<div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}>
|
||||
<div className='column' style={style} onWheel={this.handleWheel}>
|
||||
{header}
|
||||
{this.props.children}
|
||||
</div>
|
||||
|
@@ -1,12 +1,19 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
|
||||
const style = {
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
justifyContent: 'flex-start',
|
||||
overflowX: 'auto'
|
||||
};
|
||||
|
||||
const ColumnsArea = React.createClass({
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', flex: '1', marginRight: '10px', marginBottom: '10px', overflowX: 'auto' }}>
|
||||
<div className='columns-area' style={style}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -4,11 +4,62 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ReplyIndicator from './reply_indicator';
|
||||
import UploadButton from './upload_button';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
|
||||
const getTokenForSuggestions = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 2 || word[0] !== '@') {
|
||||
return null;
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase().slice(1);
|
||||
|
||||
if (word.length > 0) {
|
||||
return word;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSuggestionValue = suggestion => suggestion.completion;
|
||||
|
||||
const renderSuggestion = suggestion => (
|
||||
<span>{suggestion.label}</span>
|
||||
);
|
||||
|
||||
const textareaStyle = {
|
||||
display: 'block',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
resize: 'none',
|
||||
border: 'none',
|
||||
color: '#282c37',
|
||||
padding: '10px',
|
||||
fontFamily: 'Roboto',
|
||||
fontSize: '14px',
|
||||
margin: '0'
|
||||
};
|
||||
|
||||
const renderInputComponent = inputProps => (
|
||||
<textarea {...inputProps} placeholder='What is on your mind?' className='compose-form__textarea' style={textareaStyle} />
|
||||
);
|
||||
|
||||
const ComposeForm = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
text: React.PropTypes.string.isRequired,
|
||||
suggestions: React.PropTypes.array,
|
||||
is_submitting: React.PropTypes.bool,
|
||||
is_uploading: React.PropTypes.bool,
|
||||
in_reply_to: ImmutablePropTypes.map,
|
||||
@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
|
||||
this.refs.textarea.focus();
|
||||
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
|
||||
const textarea = node.querySelector('textarea');
|
||||
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSuggestionsClearRequested () {
|
||||
this.props.onClearSuggestions();
|
||||
},
|
||||
|
||||
onSuggestionsFetchRequested ({ value }) {
|
||||
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
|
||||
const textarea = node.querySelector('textarea');
|
||||
|
||||
if (textarea) {
|
||||
const token = getTokenForSuggestions(value, textarea.selectionStart);
|
||||
|
||||
if (token !== null) {
|
||||
this.props.onFetchSuggestions(token);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSuggestionSelected (e, { suggestionValue, method }) {
|
||||
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
|
||||
const textarea = node.querySelector('textarea');
|
||||
|
||||
if (textarea) {
|
||||
const str = this.props.text;
|
||||
this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join(''));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -47,11 +130,29 @@ const ComposeForm = React.createClass({
|
||||
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
||||
}
|
||||
|
||||
const inputProps = {
|
||||
placeholder: 'What is on your mind?',
|
||||
value: this.props.text,
|
||||
onKeyUp: this.handleKeyUp,
|
||||
onChange: this.handleChange,
|
||||
disabled: disabled
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px' }}>
|
||||
{replyArea}
|
||||
|
||||
<textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} />
|
||||
<Autosuggest
|
||||
ref='autosuggest'
|
||||
suggestions={this.props.suggestions}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderSuggestion={renderSuggestion}
|
||||
renderInputComponent={renderInputComponent}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
|
||||
|
@@ -1,12 +1,21 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
|
||||
const style = {
|
||||
boxSizing: 'border-box',
|
||||
background: '#454b5e',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflowY: 'auto'
|
||||
};
|
||||
|
||||
const Drawer = React.createClass({
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div style={{ width: '280px', flex: '0 0 auto', boxSizing: 'border-box', background: '#454b5e', margin: '10px', marginRight: '0', padding: '0', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className='drawer' style={style}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -33,7 +33,7 @@ const FollowForm = React.createClass({
|
||||
render () {
|
||||
return (
|
||||
<div style={{ display: 'flex', lineHeight: '20px', padding: '10px', background: '#373b4a' }}>
|
||||
<input type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
|
||||
<input autoComplete='off' type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
|
||||
<div style={{ padding: '10px', paddingRight: '0' }}><IconButton title='Follow' size={20} icon='user-plus' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -17,9 +17,9 @@ const NavigationBar = React.createClass({
|
||||
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
|
||||
<Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link>
|
||||
|
||||
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
|
||||
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>
|
||||
<a href='/settings' style={{ color: '#9baec8', textDecoration: 'none' }}>Settings <i className='fa fa fa-cog' /></a>
|
||||
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
|
||||
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
|
||||
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}>Settings</a> · <Link to='/statuses/all' style={{ color: 'inherit', textDecoration: 'none' }}>Public timeline</Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}>Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const outerStyle = {
|
||||
background: '#373b4a',
|
||||
margin: '10px',
|
||||
flex: '0 0 auto',
|
||||
marginBottom: '0',
|
||||
display: 'flex'
|
||||
};
|
||||
|
||||
const tabStyle = {
|
||||
display: 'block',
|
||||
flex: '1 1 auto',
|
||||
padding: '10px',
|
||||
color: '#fff',
|
||||
textDecoration: 'none',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
borderBottom: '2px solid #373b4a'
|
||||
};
|
||||
|
||||
const tabActiveStyle = {
|
||||
borderBottom: '2px solid #2b90d9',
|
||||
color: '#2b90d9'
|
||||
};
|
||||
|
||||
const TabsBar = () => {
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> Compose</Link>
|
||||
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/home'><i className='fa fa-fw fa-home' /> Home</Link>
|
||||
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/mentions'><i className='fa fa-fw fa-at' /> Mentions</Link>
|
||||
<Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/all'><i className='fa fa-fw fa-globe' /> Public</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabsBar;
|
@@ -24,7 +24,7 @@ const UploadButton = React.createClass({
|
||||
return (
|
||||
<div>
|
||||
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
|
||||
<i className='fa fa-fw fa-photo' /> Add images
|
||||
<i className='fa fa-fw fa-photo' /> Add media
|
||||
</Button>
|
||||
|
||||
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
|
||||
|
@@ -1,31 +1,52 @@
|
||||
import { connect } from 'react-redux';
|
||||
import ComposeForm from '../components/compose_form';
|
||||
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
|
||||
import { selectStatus } from '../../../reducers/timelines';
|
||||
import { connect } from 'react-redux';
|
||||
import ComposeForm from '../components/compose_form';
|
||||
import {
|
||||
changeCompose,
|
||||
submitCompose,
|
||||
cancelReplyCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions
|
||||
} from '../../../actions/compose';
|
||||
import { makeGetStatus } from '../../../selectors';
|
||||
|
||||
const mapStateToProps = function (state, props) {
|
||||
return {
|
||||
text: state.getIn(['compose', 'text']),
|
||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = function (state, props) {
|
||||
return {
|
||||
text: state.getIn(['compose', 'text']),
|
||||
suggestions: state.getIn(['compose', 'suggestions']),
|
||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = function (dispatch) {
|
||||
return {
|
||||
onChange: function (text) {
|
||||
onChange (text) {
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
|
||||
onSubmit: function () {
|
||||
onSubmit () {
|
||||
dispatch(submitCompose());
|
||||
},
|
||||
|
||||
onCancelReply: function () {
|
||||
onCancelReply () {
|
||||
dispatch(cancelReplyCompose());
|
||||
},
|
||||
|
||||
onClearSuggestions () {
|
||||
dispatch(clearComposeSuggestions());
|
||||
},
|
||||
|
||||
onFetchSuggestions (token) {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
|
||||
|
@@ -0,0 +1,48 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { closeModal } from '../../../actions/modal';
|
||||
import Lightbox from '../../../components/lightbox';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
url: state.getIn(['modal', 'url']),
|
||||
isVisible: state.getIn(['modal', 'open'])
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onCloseClicked () {
|
||||
dispatch(closeModal());
|
||||
},
|
||||
|
||||
onOverlayClicked () {
|
||||
dispatch(closeModal());
|
||||
}
|
||||
});
|
||||
|
||||
const imageStyle = {
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
margin: '0 auto'
|
||||
};
|
||||
|
||||
const Modal = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
url: React.PropTypes.string,
|
||||
isVisible: React.PropTypes.bool,
|
||||
onCloseClicked: React.PropTypes.func,
|
||||
onOverlayClicked: React.PropTypes.func
|
||||
},
|
||||
|
||||
render () {
|
||||
const { url, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<Lightbox {...other}>
|
||||
<img src={url} style={imageStyle} />
|
||||
</Lightbox>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Modal);
|
@@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
||||
import NavigationBar from '../components/navigation_bar';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: state.getIn(['timelines', 'accounts', state.getIn(['timelines', 'me'])])
|
||||
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(NavigationBar);
|
||||
|
@@ -4,14 +4,10 @@ import {
|
||||
dismissNotification,
|
||||
clearNotifications
|
||||
} from '../../../actions/notifications';
|
||||
import { getNotifications } from '../../../selectors';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
notifications: state.get('notifications').map((item, i) => ({
|
||||
message: item.get('message'),
|
||||
title: item.get('title'),
|
||||
key: item.get('key'),
|
||||
dismissAfter: 5000
|
||||
})).toJS()
|
||||
notifications: getNotifications(state)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
|
@@ -1,51 +1,16 @@
|
||||
import { connect } from 'react-redux';
|
||||
import StatusList from '../../../components/status_list';
|
||||
import { replyCompose } from '../../../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite
|
||||
} from '../../../actions/interactions';
|
||||
import { expandTimeline } from '../../../actions/timelines';
|
||||
import { selectStatus } from '../../../reducers/timelines';
|
||||
import { deleteStatus } from '../../../actions/statuses';
|
||||
import { connect } from 'react-redux';
|
||||
import StatusList from '../../../components/status_list';
|
||||
import { expandTimeline } from '../../../actions/timelines';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const mapStateToProps = function (state, props) {
|
||||
return {
|
||||
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
};
|
||||
};
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statusIds: state.getIn(['timelines', props.type], Immutable.List())
|
||||
});
|
||||
|
||||
const mapDispatchToProps = function (dispatch, props) {
|
||||
return {
|
||||
onReply (status) {
|
||||
dispatch(replyCompose(status));
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
onReblog (status) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
dispatch(reblog(status));
|
||||
}
|
||||
},
|
||||
|
||||
onScrollToBottom () {
|
||||
dispatch(expandTimeline(props.type));
|
||||
},
|
||||
|
||||
onDelete (status) {
|
||||
dispatch(deleteStatus(status.get('id')));
|
||||
dispatch(expandTimeline(props.type, props.id));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@@ -1,52 +1,43 @@
|
||||
import ColumnsArea from './components/columns_area';
|
||||
import Column from './components/column';
|
||||
import Drawer from './components/drawer';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
import FollowFormContainer from './containers/follow_form_container';
|
||||
import UploadFormContainer from './containers/upload_form_container';
|
||||
import StatusListContainer from './containers/status_list_container';
|
||||
import NotificationsContainer from './containers/notifications_container';
|
||||
import NavigationContainer from './containers/navigation_container';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import LoadingBarContainer from './containers/loading_bar_container';
|
||||
import HomeTimeline from '../home_timeline';
|
||||
import MentionsTimeline from '../mentions_timeline';
|
||||
import Compose from '../compose';
|
||||
import MediaQuery from 'react-responsive';
|
||||
import TabsBar from './components/tabs_bar';
|
||||
import ModalContainer from './containers/modal_container';
|
||||
|
||||
const UI = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const layoutBreakpoint = 1024;
|
||||
|
||||
return (
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}>
|
||||
<Drawer>
|
||||
<div style={{ flex: '1 1 auto' }}>
|
||||
<NavigationContainer />
|
||||
<ComposeFormContainer />
|
||||
<UploadFormContainer />
|
||||
</div>
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', width: '100%', height: '100%', background: '#1a1c23' }}>
|
||||
<MediaQuery maxWidth={layoutBreakpoint}>
|
||||
<TabsBar />
|
||||
</MediaQuery>
|
||||
|
||||
<FollowFormContainer />
|
||||
</Drawer>
|
||||
<MediaQuery maxWidth={layoutBreakpoint} component={ColumnsArea}>
|
||||
{this.props.children}
|
||||
</MediaQuery>
|
||||
|
||||
<ColumnsArea>
|
||||
<Column icon='home' heading='Home'>
|
||||
<StatusListContainer type='home' />
|
||||
</Column>
|
||||
|
||||
<Column icon='at' heading='Mentions'>
|
||||
<StatusListContainer type='mentions' />
|
||||
</Column>
|
||||
|
||||
<Column>
|
||||
<MediaQuery minWidth={layoutBreakpoint}>
|
||||
<ColumnsArea>
|
||||
<Compose />
|
||||
<HomeTimeline trackScroll={false} />
|
||||
<MentionsTimeline trackScroll={false} />
|
||||
{this.props.children}
|
||||
</Column>
|
||||
</ColumnsArea>
|
||||
</ColumnsArea>
|
||||
</MediaQuery>
|
||||
|
||||
<NotificationsContainer />
|
||||
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
|
||||
<ModalContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
31
app/assets/javascripts/components/middleware/errors.jsx
Normal file
31
app/assets/javascripts/components/middleware/errors.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { showNotification } from '../actions/notifications';
|
||||
|
||||
const defaultFailSuffix = 'FAIL';
|
||||
|
||||
export default function errorsMiddleware() {
|
||||
return ({ dispatch }) => next => action => {
|
||||
if (action.type) {
|
||||
const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
|
||||
|
||||
if (action.type.match(isFail)) {
|
||||
if (action.error.response) {
|
||||
const { data, status, statusText } = action.error.response;
|
||||
|
||||
let message = statusText;
|
||||
let title = `${status}`;
|
||||
|
||||
if (data.error) {
|
||||
message = data.error;
|
||||
}
|
||||
|
||||
dispatch(showNotification(title, message));
|
||||
} else {
|
||||
console.error(action.error);
|
||||
dispatch(showNotification('Oops!', 'An unexpected error occurred. Inspect the console for more details'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
};
|
89
app/assets/javascripts/components/reducers/accounts.jsx
Normal file
89
app/assets/javascripts/components/reducers/accounts.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
ACCOUNT_SET_SELF,
|
||||
ACCOUNT_FETCH_SUCCESS,
|
||||
FOLLOWERS_FETCH_SUCCESS,
|
||||
FOLLOWING_FETCH_SUCCESS,
|
||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
||||
} from '../actions/accounts';
|
||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
||||
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
|
||||
import {
|
||||
REBLOG_SUCCESS,
|
||||
UNREBLOG_SUCCESS,
|
||||
FAVOURITE_SUCCESS,
|
||||
UNFAVOURITE_SUCCESS,
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_SUCCESS
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
TIMELINE_REFRESH_SUCCESS,
|
||||
TIMELINE_UPDATE,
|
||||
TIMELINE_EXPAND_SUCCESS
|
||||
} from '../actions/timelines';
|
||||
import {
|
||||
STATUS_FETCH_SUCCESS,
|
||||
CONTEXT_FETCH_SUCCESS
|
||||
} from '../actions/statuses';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
|
||||
|
||||
const normalizeAccounts = (state, accounts) => {
|
||||
accounts.forEach(account => {
|
||||
state = normalizeAccount(state, account);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const normalizeAccountFromStatus = (state, status) => {
|
||||
state = normalizeAccount(state, status.account);
|
||||
|
||||
if (status.reblog && status.reblog.account) {
|
||||
state = normalizeAccount(state, status.reblog.account);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const normalizeAccountsFromStatuses = (state, statuses) => {
|
||||
statuses.forEach(status => {
|
||||
state = normalizeAccountFromStatus(state, status);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const initialState = Immutable.Map();
|
||||
|
||||
export default function accounts(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case ACCOUNT_SET_SELF:
|
||||
case ACCOUNT_FETCH_SUCCESS:
|
||||
case FOLLOW_SUBMIT_SUCCESS:
|
||||
return normalizeAccount(state, action.account);
|
||||
case SUGGESTIONS_FETCH_SUCCESS:
|
||||
case FOLLOWERS_FETCH_SUCCESS:
|
||||
case FOLLOWING_FETCH_SUCCESS:
|
||||
case REBLOGS_FETCH_SUCCESS:
|
||||
case FAVOURITES_FETCH_SUCCESS:
|
||||
return normalizeAccounts(state, action.accounts);
|
||||
case TIMELINE_REFRESH_SUCCESS:
|
||||
case TIMELINE_EXPAND_SUCCESS:
|
||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return normalizeAccountsFromStatuses(state, action.statuses);
|
||||
case REBLOG_SUCCESS:
|
||||
case FAVOURITE_SUCCESS:
|
||||
case UNREBLOG_SUCCESS:
|
||||
case UNFAVOURITE_SUCCESS:
|
||||
return normalizeAccountFromStatus(state, action.response);
|
||||
case TIMELINE_UPDATE:
|
||||
case STATUS_FETCH_SUCCESS:
|
||||
return normalizeAccountFromStatus(state, action.status);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
@@ -2,6 +2,7 @@ import {
|
||||
COMPOSE_CHANGE,
|
||||
COMPOSE_REPLY,
|
||||
COMPOSE_REPLY_CANCEL,
|
||||
COMPOSE_MENTION,
|
||||
COMPOSE_SUBMIT_REQUEST,
|
||||
COMPOSE_SUBMIT_SUCCESS,
|
||||
COMPOSE_SUBMIT_FAIL,
|
||||
@@ -9,11 +10,13 @@ import {
|
||||
COMPOSE_UPLOAD_SUCCESS,
|
||||
COMPOSE_UPLOAD_FAIL,
|
||||
COMPOSE_UPLOAD_UNDO,
|
||||
COMPOSE_UPLOAD_PROGRESS
|
||||
} from '../actions/compose';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
COMPOSE_UPLOAD_PROGRESS,
|
||||
COMPOSE_SUGGESTIONS_CLEAR,
|
||||
COMPOSE_SUGGESTIONS_READY
|
||||
} from '../actions/compose';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
||||
import Immutable from 'immutable';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
text: '',
|
||||
@@ -21,7 +24,8 @@ const initialState = Immutable.Map({
|
||||
is_submitting: false,
|
||||
is_uploading: false,
|
||||
progress: 0,
|
||||
media_attachments: Immutable.List([]),
|
||||
media_attachments: Immutable.List(),
|
||||
suggestions: [],
|
||||
me: null
|
||||
});
|
||||
|
||||
@@ -32,7 +36,7 @@ function statusToTextMentions(state, status) {
|
||||
if (status.getIn(['account', 'id']) !== me) {
|
||||
set = set.add(`@${status.getIn(['account', 'acct'])} `);
|
||||
}
|
||||
|
||||
|
||||
return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
|
||||
};
|
||||
|
||||
@@ -92,6 +96,12 @@ export default function compose(state = initialState, action) {
|
||||
return removeMedia(state, action.media_id);
|
||||
case COMPOSE_UPLOAD_PROGRESS:
|
||||
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
||||
case COMPOSE_MENTION:
|
||||
return state.update('text', text => `${text}@${action.account.get('acct')} `);
|
||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||
return state.set('suggestions', []);
|
||||
case COMPOSE_SUGGESTIONS_READY:
|
||||
return state.set('suggestions', action.accounts);
|
||||
case TIMELINE_DELETE:
|
||||
if (action.id === state.get('in_reply_to')) {
|
||||
return state.set('in_reply_to', null);
|
||||
|
@@ -3,7 +3,7 @@ import {
|
||||
FOLLOW_SUBMIT_REQUEST,
|
||||
FOLLOW_SUBMIT_SUCCESS,
|
||||
FOLLOW_SUBMIT_FAIL
|
||||
} from '../actions/follow';
|
||||
} from '../actions/follow';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
|
@@ -5,6 +5,11 @@ import compose from './compose';
|
||||
import follow from './follow';
|
||||
import notifications from './notifications';
|
||||
import { loadingBarReducer } from 'react-redux-loading-bar';
|
||||
import modal from './modal';
|
||||
import user_lists from './user_lists';
|
||||
import accounts from './accounts';
|
||||
import statuses from './statuses';
|
||||
import relationships from './relationships';
|
||||
|
||||
export default combineReducers({
|
||||
timelines,
|
||||
@@ -13,4 +18,9 @@ export default combineReducers({
|
||||
follow,
|
||||
notifications,
|
||||
loadingBar: loadingBarReducer,
|
||||
modal,
|
||||
user_lists,
|
||||
accounts,
|
||||
statuses,
|
||||
relationships
|
||||
});
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { ACCESS_TOKEN_SET } from '../actions/meta';
|
||||
import Immutable from 'immutable';
|
||||
import { ACCESS_TOKEN_SET } from '../actions/meta';
|
||||
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map();
|
||||
|
||||
@@ -7,6 +8,8 @@ export default function meta(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case ACCESS_TOKEN_SET:
|
||||
return state.set('access_token', action.token);
|
||||
case ACCOUNT_SET_SELF:
|
||||
return state.set('me', action.account.id);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
21
app/assets/javascripts/components/reducers/modal.jsx
Normal file
21
app/assets/javascripts/components/reducers/modal.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { MEDIA_OPEN, MODAL_CLOSE } from '../actions/modal';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
url: '',
|
||||
open: false
|
||||
});
|
||||
|
||||
export default function modal(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case MEDIA_OPEN:
|
||||
return state.withMutations(map => {
|
||||
map.set('url', action.url);
|
||||
map.set('open', true);
|
||||
});
|
||||
case MODAL_CLOSE:
|
||||
return state.set('open', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
@@ -1,68 +1,20 @@
|
||||
import { COMPOSE_SUBMIT_FAIL, COMPOSE_UPLOAD_FAIL } from '../actions/compose';
|
||||
import { FOLLOW_SUBMIT_FAIL } from '../actions/follow';
|
||||
import {
|
||||
REBLOG_FAIL,
|
||||
UNREBLOG_FAIL,
|
||||
FAVOURITE_FAIL,
|
||||
UNFAVOURITE_FAIL
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
TIMELINE_REFRESH_FAIL,
|
||||
TIMELINE_EXPAND_FAIL
|
||||
} from '../actions/timelines';
|
||||
import { NOTIFICATION_DISMISS, NOTIFICATION_CLEAR } from '../actions/notifications';
|
||||
import {
|
||||
ACCOUNT_FETCH_FAIL,
|
||||
ACCOUNT_FOLLOW_FAIL,
|
||||
ACCOUNT_UNFOLLOW_FAIL,
|
||||
ACCOUNT_TIMELINE_FETCH_FAIL,
|
||||
ACCOUNT_TIMELINE_EXPAND_FAIL
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
STATUS_FETCH_FAIL,
|
||||
STATUS_DELETE_FAIL
|
||||
} from '../actions/statuses';
|
||||
import Immutable from 'immutable';
|
||||
NOTIFICATION_SHOW,
|
||||
NOTIFICATION_DISMISS,
|
||||
NOTIFICATION_CLEAR
|
||||
} from '../actions/notifications';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.List();
|
||||
|
||||
function notificationFromError(state, error) {
|
||||
let n = Immutable.Map({
|
||||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||
message: ''
|
||||
});
|
||||
|
||||
if (error.response) {
|
||||
n = n.withMutations(map => {
|
||||
map.set('message', error.response.statusText);
|
||||
map.set('title', `${error.response.status}`);
|
||||
});
|
||||
} else {
|
||||
n = n.set('message', `${error}`);
|
||||
}
|
||||
|
||||
return state.push(n);
|
||||
};
|
||||
const initialState = Immutable.List([]);
|
||||
|
||||
export default function notifications(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case COMPOSE_SUBMIT_FAIL:
|
||||
case COMPOSE_UPLOAD_FAIL:
|
||||
case FOLLOW_SUBMIT_FAIL:
|
||||
case REBLOG_FAIL:
|
||||
case FAVOURITE_FAIL:
|
||||
case TIMELINE_REFRESH_FAIL:
|
||||
case TIMELINE_EXPAND_FAIL:
|
||||
case ACCOUNT_FETCH_FAIL:
|
||||
case ACCOUNT_FOLLOW_FAIL:
|
||||
case ACCOUNT_UNFOLLOW_FAIL:
|
||||
case ACCOUNT_TIMELINE_FETCH_FAIL:
|
||||
case ACCOUNT_TIMELINE_EXPAND_FAIL:
|
||||
case STATUS_FETCH_FAIL:
|
||||
case STATUS_DELETE_FAIL:
|
||||
case UNREBLOG_FAIL:
|
||||
case UNFAVOURITE_FAIL:
|
||||
return notificationFromError(state, action.error);
|
||||
case NOTIFICATION_SHOW:
|
||||
return state.push(Immutable.Map({
|
||||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||
title: action.title,
|
||||
message: action.message
|
||||
}));
|
||||
case NOTIFICATION_DISMISS:
|
||||
return state.filterNot(item => item.get('key') === action.notification.key);
|
||||
case NOTIFICATION_CLEAR:
|
||||
|
34
app/assets/javascripts/components/reducers/relationships.jsx
Normal file
34
app/assets/javascripts/components/reducers/relationships.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
ACCOUNT_FOLLOW_SUCCESS,
|
||||
ACCOUNT_UNFOLLOW_SUCCESS,
|
||||
ACCOUNT_BLOCK_SUCCESS,
|
||||
ACCOUNT_UNBLOCK_SUCCESS,
|
||||
RELATIONSHIPS_FETCH_SUCCESS
|
||||
} from '../actions/accounts';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship));
|
||||
|
||||
const normalizeRelationships = (state, relationships) => {
|
||||
relationships.forEach(relationship => {
|
||||
state = normalizeRelationship(state, relationship);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const initialState = Immutable.Map();
|
||||
|
||||
export default function relationships(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case ACCOUNT_FOLLOW_SUCCESS:
|
||||
case ACCOUNT_UNFOLLOW_SUCCESS:
|
||||
case ACCOUNT_BLOCK_SUCCESS:
|
||||
case ACCOUNT_UNBLOCK_SUCCESS:
|
||||
return normalizeRelationship(state, action.relationship);
|
||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||
return normalizeRelationships(state, action.relationships);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
73
app/assets/javascripts/components/reducers/statuses.jsx
Normal file
73
app/assets/javascripts/components/reducers/statuses.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
REBLOG_SUCCESS,
|
||||
UNREBLOG_SUCCESS,
|
||||
FAVOURITE_SUCCESS,
|
||||
UNFAVOURITE_SUCCESS
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
STATUS_FETCH_SUCCESS,
|
||||
CONTEXT_FETCH_SUCCESS
|
||||
} from '../actions/statuses';
|
||||
import {
|
||||
TIMELINE_REFRESH_SUCCESS,
|
||||
TIMELINE_UPDATE,
|
||||
TIMELINE_DELETE,
|
||||
TIMELINE_EXPAND_SUCCESS
|
||||
} from '../actions/timelines';
|
||||
import {
|
||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
||||
} from '../actions/accounts';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const normalizeStatus = (state, status) => {
|
||||
status.account = status.account.id;
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
state = normalizeStatus(state, status.reblog);
|
||||
status.reblog = status.reblog.id;
|
||||
}
|
||||
|
||||
return state.set(status.id, Immutable.fromJS(status));
|
||||
};
|
||||
|
||||
const normalizeStatuses = (state, statuses) => {
|
||||
statuses.forEach(status => {
|
||||
state = normalizeStatus(state, status);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const deleteStatus = (state, id, references) => {
|
||||
references.forEach(ref => {
|
||||
state = deleteStatus(state, ref[0], []);
|
||||
});
|
||||
|
||||
return state.delete(id);
|
||||
};
|
||||
|
||||
const initialState = Immutable.Map();
|
||||
|
||||
export default function statuses(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case TIMELINE_UPDATE:
|
||||
case STATUS_FETCH_SUCCESS:
|
||||
return normalizeStatus(state, action.status);
|
||||
case REBLOG_SUCCESS:
|
||||
case UNREBLOG_SUCCESS:
|
||||
case FAVOURITE_SUCCESS:
|
||||
case UNFAVOURITE_SUCCESS:
|
||||
return normalizeStatus(state, action.response);
|
||||
case TIMELINE_REFRESH_SUCCESS:
|
||||
case TIMELINE_EXPAND_SUCCESS:
|
||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return normalizeStatuses(state, action.statuses);
|
||||
case TIMELINE_DELETE:
|
||||
return deleteStatus(state, action.id, action.references);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
@@ -3,243 +3,167 @@ import {
|
||||
TIMELINE_UPDATE,
|
||||
TIMELINE_DELETE,
|
||||
TIMELINE_EXPAND_SUCCESS
|
||||
} from '../actions/timelines';
|
||||
} from '../actions/timelines';
|
||||
import {
|
||||
REBLOG_SUCCESS,
|
||||
UNREBLOG_SUCCESS,
|
||||
FAVOURITE_SUCCESS,
|
||||
UNFAVOURITE_SUCCESS
|
||||
} from '../actions/interactions';
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
ACCOUNT_SET_SELF,
|
||||
ACCOUNT_FETCH_SUCCESS,
|
||||
ACCOUNT_FOLLOW_SUCCESS,
|
||||
ACCOUNT_UNFOLLOW_SUCCESS,
|
||||
ACCOUNT_BLOCK_SUCCESS,
|
||||
ACCOUNT_UNBLOCK_SUCCESS,
|
||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
||||
} from '../actions/accounts';
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
STATUS_FETCH_SUCCESS,
|
||||
STATUS_DELETE_SUCCESS
|
||||
} from '../actions/statuses';
|
||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
||||
import Immutable from 'immutable';
|
||||
CONTEXT_FETCH_SUCCESS
|
||||
} from '../actions/statuses';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
home: Immutable.List([]),
|
||||
mentions: Immutable.List([]),
|
||||
statuses: Immutable.Map(),
|
||||
accounts: Immutable.Map(),
|
||||
home: Immutable.List(),
|
||||
mentions: Immutable.List(),
|
||||
public: Immutable.List(),
|
||||
tag: Immutable.List(),
|
||||
accounts_timelines: Immutable.Map(),
|
||||
me: null,
|
||||
ancestors: Immutable.Map(),
|
||||
descendants: Immutable.Map(),
|
||||
relationships: Immutable.Map()
|
||||
descendants: Immutable.Map()
|
||||
});
|
||||
|
||||
export function selectStatus(state, id) {
|
||||
let status = state.getIn(['timelines', 'statuses', id], null);
|
||||
const normalizeStatus = (state, status) => {
|
||||
const replyToId = status.get('in_reply_to_id');
|
||||
const id = status.get('id');
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
status = status.set('account', selectAccount(state, status.get('account')));
|
||||
|
||||
if (status.get('reblog') !== null) {
|
||||
status = status.set('reblog', selectStatus(state, status.get('reblog')));
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
export function selectAccount(state, id) {
|
||||
let account = state.getIn(['timelines', 'accounts', id], null);
|
||||
|
||||
if (account === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return account.set('relationship', state.getIn(['timelines', 'relationships', id]));
|
||||
};
|
||||
|
||||
function normalizeStatus(state, status) {
|
||||
// Separate account
|
||||
let account = status.get('account');
|
||||
status = status.set('account', account.get('id'));
|
||||
|
||||
// Separate reblog, repeat for reblog
|
||||
let reblog = status.get('reblog');
|
||||
|
||||
if (reblog !== null) {
|
||||
status = status.set('reblog', reblog.get('id'));
|
||||
state = normalizeStatus(state, reblog);
|
||||
}
|
||||
|
||||
// Replies
|
||||
if (status.get('in_reply_to_id')) {
|
||||
state = state.updateIn(['descendants', status.get('in_reply_to_id')], set => {
|
||||
if (!Immutable.OrderedSet.isOrderedSet(set)) {
|
||||
return Immutable.OrderedSet([status.get('id')]);
|
||||
} else {
|
||||
return set.add(status.get('id'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return state.withMutations(map => {
|
||||
if (status.get('in_reply_to_id')) {
|
||||
map.updateIn(['descendants', status.get('in_reply_to_id')], Immutable.OrderedSet(), set => set.add(status.get('id')));
|
||||
map.updateIn(['ancestors', status.get('id')], Immutable.OrderedSet(), set => set.add(status.get('in_reply_to_id')));
|
||||
if (replyToId) {
|
||||
if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
|
||||
state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
|
||||
}
|
||||
|
||||
map.setIn(['accounts', account.get('id')], account);
|
||||
map.setIn(['statuses', status.get('id')], status);
|
||||
});
|
||||
};
|
||||
|
||||
function normalizeTimeline(state, timeline, statuses) {
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
state = state.setIn([timeline, i], status.get('id'));
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
function appendNormalizedTimeline(state, timeline, statuses) {
|
||||
let moreIds = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
moreIds = moreIds.set(i, status.get('id'));
|
||||
});
|
||||
|
||||
return state.update(timeline, list => list.push(...moreIds));
|
||||
};
|
||||
|
||||
function normalizeAccountTimeline(state, accountId, statuses) {
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
state = state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.set(i, status.get('id')));
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
function appendNormalizedAccountTimeline(state, accountId, statuses) {
|
||||
let moreIds = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
moreIds = moreIds.set(i, status.get('id'));
|
||||
});
|
||||
|
||||
return state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.push(...moreIds));
|
||||
};
|
||||
|
||||
function updateTimeline(state, timeline, status) {
|
||||
state = normalizeStatus(state, status);
|
||||
state = state.update(timeline, list => list.unshift(status.get('id')));
|
||||
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List(), list => list.unshift(status.get('id')));
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
function deleteStatus(state, id) {
|
||||
const status = state.getIn(['statuses', id]);
|
||||
|
||||
if (!status) {
|
||||
return state;
|
||||
if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
|
||||
state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const normalizeTimeline = (state, timeline, statuses, replace = false) => {
|
||||
let ids = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
ids = ids.set(i, status.get('id'));
|
||||
});
|
||||
|
||||
return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids)));
|
||||
};
|
||||
|
||||
const appendNormalizedTimeline = (state, timeline, statuses) => {
|
||||
let moreIds = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
moreIds = moreIds.set(i, status.get('id'));
|
||||
});
|
||||
|
||||
return state.update(timeline, Immutable.List(), list => list.push(...moreIds));
|
||||
};
|
||||
|
||||
const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
|
||||
let ids = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
ids = ids.set(i, status.get('id'));
|
||||
});
|
||||
|
||||
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
|
||||
};
|
||||
|
||||
const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
|
||||
let moreIds = Immutable.List([]);
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
state = normalizeStatus(state, status);
|
||||
moreIds = moreIds.set(i, status.get('id'));
|
||||
});
|
||||
|
||||
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
|
||||
};
|
||||
|
||||
const updateTimeline = (state, timeline, status, references) => {
|
||||
state = normalizeStatus(state, status);
|
||||
|
||||
state = state.update(timeline, Immutable.List(), list => {
|
||||
if (list.includes(status.get('id'))) {
|
||||
return list;
|
||||
}
|
||||
|
||||
const reblogOfId = status.getIn(['reblog', 'id'], null);
|
||||
|
||||
if (reblogOfId !== null) {
|
||||
list = list.filterNot(itemId => references.includes(itemId));
|
||||
}
|
||||
|
||||
return list.unshift(status.get('id'));
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const deleteStatus = (state, id, accountId, references) => {
|
||||
// Remove references from timelines
|
||||
['home', 'mentions'].forEach(function (timeline) {
|
||||
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
|
||||
state = state.update(timeline, list => list.filterNot(item => item === id));
|
||||
});
|
||||
|
||||
// Remove references from account timelines
|
||||
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id));
|
||||
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
|
||||
|
||||
// Remove reblogs of deleted status
|
||||
const references = state.get('statuses').filter(item => item.get('reblog') === id);
|
||||
|
||||
references.forEach(referencingId => {
|
||||
state = deleteStatus(state, referencingId);
|
||||
// Remove references from context
|
||||
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
|
||||
state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
|
||||
});
|
||||
|
||||
// Remove normalized status
|
||||
return state.deleteIn(['statuses', id]);
|
||||
state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => {
|
||||
state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
|
||||
});
|
||||
|
||||
state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
|
||||
|
||||
// Remove reblogs of deleted status
|
||||
references.forEach(ref => {
|
||||
state = deleteStatus(state, ref[0], ref[1], []);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
function normalizeAccount(state, account, relationship) {
|
||||
if (relationship) {
|
||||
state = normalizeRelationship(state, relationship);
|
||||
}
|
||||
|
||||
return state.setIn(['accounts', account.get('id')], account);
|
||||
};
|
||||
|
||||
function normalizeRelationship(state, relationship) {
|
||||
return state.setIn(['relationships', relationship.get('id')], relationship);
|
||||
};
|
||||
|
||||
function setSelf(state, account) {
|
||||
state = normalizeAccount(state, account);
|
||||
return state.set('me', account.get('id'));
|
||||
};
|
||||
|
||||
function normalizeContext(state, status, ancestors, descendants) {
|
||||
state = normalizeStatus(state, status);
|
||||
|
||||
let ancestorsIds = ancestors.map(ancestor => {
|
||||
state = normalizeStatus(state, ancestor);
|
||||
return ancestor.get('id');
|
||||
}).toOrderedSet();
|
||||
|
||||
let descendantsIds = descendants.map(descendant => {
|
||||
state = normalizeStatus(state, descendant);
|
||||
return descendant.get('id');
|
||||
}).toOrderedSet();
|
||||
const normalizeContext = (state, id, ancestors, descendants) => {
|
||||
const ancestorsIds = ancestors.map(ancestor => ancestor.get('id'));
|
||||
const descendantsIds = descendants.map(descendant => descendant.get('id'));
|
||||
|
||||
return state.withMutations(map => {
|
||||
map.setIn(['ancestors', status.get('id')], ancestorsIds);
|
||||
map.setIn(['descendants', status.get('id')], descendantsIds);
|
||||
map.setIn(['ancestors', id], ancestorsIds);
|
||||
map.setIn(['descendants', id], descendantsIds);
|
||||
});
|
||||
};
|
||||
|
||||
export default function timelines(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case TIMELINE_REFRESH_SUCCESS:
|
||||
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
||||
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.replace);
|
||||
case TIMELINE_EXPAND_SUCCESS:
|
||||
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
||||
case TIMELINE_UPDATE:
|
||||
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
|
||||
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
|
||||
case TIMELINE_DELETE:
|
||||
case STATUS_DELETE_SUCCESS:
|
||||
return deleteStatus(state, action.id);
|
||||
case REBLOG_SUCCESS:
|
||||
case FAVOURITE_SUCCESS:
|
||||
case UNREBLOG_SUCCESS:
|
||||
case UNFAVOURITE_SUCCESS:
|
||||
return normalizeStatus(state, Immutable.fromJS(action.response));
|
||||
case ACCOUNT_SET_SELF:
|
||||
return setSelf(state, Immutable.fromJS(action.account));
|
||||
case ACCOUNT_FETCH_SUCCESS:
|
||||
case FOLLOW_SUBMIT_SUCCESS:
|
||||
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
|
||||
case ACCOUNT_FOLLOW_SUCCESS:
|
||||
case ACCOUNT_UNFOLLOW_SUCCESS:
|
||||
case ACCOUNT_UNBLOCK_SUCCESS:
|
||||
case ACCOUNT_BLOCK_SUCCESS:
|
||||
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
|
||||
case STATUS_FETCH_SUCCESS:
|
||||
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));
|
||||
return deleteStatus(state, action.id, action.accountId, action.references);
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
|
||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||
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));
|
||||
default:
|
||||
|
35
app/assets/javascripts/components/reducers/user_lists.jsx
Normal file
35
app/assets/javascripts/components/reducers/user_lists.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
FOLLOWERS_FETCH_SUCCESS,
|
||||
FOLLOWING_FETCH_SUCCESS
|
||||
} from '../actions/accounts';
|
||||
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
|
||||
import {
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_SUCCESS
|
||||
} from '../actions/interactions';
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
followers: Immutable.Map(),
|
||||
following: Immutable.Map(),
|
||||
suggestions: Immutable.List(),
|
||||
reblogged_by: Immutable.Map(),
|
||||
favourited_by: Immutable.Map()
|
||||
});
|
||||
|
||||
export default function userLists(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case FOLLOWERS_FETCH_SUCCESS:
|
||||
return state.setIn(['followers', action.id], Immutable.List(action.accounts.map(item => item.id)));
|
||||
case FOLLOWING_FETCH_SUCCESS:
|
||||
return state.setIn(['following', action.id], Immutable.List(action.accounts.map(item => item.id)));
|
||||
case SUGGESTIONS_FETCH_SUCCESS:
|
||||
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id)));
|
||||
case REBLOGS_FETCH_SUCCESS:
|
||||
return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
|
||||
case FAVOURITES_FETCH_SUCCESS:
|
||||
return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
68
app/assets/javascripts/components/selectors/index.jsx
Normal file
68
app/assets/javascripts/components/selectors/index.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import Immutable from 'immutable';
|
||||
|
||||
const getStatuses = state => state.get('statuses');
|
||||
const getAccounts = state => state.get('accounts');
|
||||
|
||||
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||
const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
|
||||
|
||||
export const makeGetAccount = () => {
|
||||
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
|
||||
if (base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base.set('relationship', relationship);
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
|
||||
if (base === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return assembleStatus(base.get('id'), statuses, accounts);
|
||||
});
|
||||
};
|
||||
|
||||
const assembleStatus = (id, statuses, accounts) => {
|
||||
let status = statuses.get(id, null);
|
||||
let reblog = null;
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null) {
|
||||
reblog = statuses.get(status.get('reblog'), null);
|
||||
|
||||
if (reblog !== null) {
|
||||
reblog = reblog.set('account', accounts.get(reblog.get('account')));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
|
||||
};
|
||||
|
||||
const getNotificationsBase = state => state.get('notifications');
|
||||
|
||||
export const getNotifications = createSelector([getNotificationsBase], (base) => {
|
||||
let arr = [];
|
||||
|
||||
base.forEach(item => {
|
||||
arr.push({
|
||||
message: item.get('message'),
|
||||
title: item.get('title'),
|
||||
key: item.get('key'),
|
||||
dismissAfter: 5000
|
||||
});
|
||||
});
|
||||
|
||||
return arr;
|
||||
});
|
@@ -2,9 +2,10 @@ import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import appReducer from '../reducers';
|
||||
import { loadingBarMiddleware } from 'react-redux-loading-bar';
|
||||
import errorsMiddleware from '../middleware/errors';
|
||||
|
||||
export default function configureStore(initialState) {
|
||||
return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
|
||||
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
|
||||
})), window.devToolsExtension ? window.devToolsExtension() : f => f));
|
||||
}), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
|
||||
};
|
||||
|
@@ -6,7 +6,8 @@
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
color: #9baec8;
|
||||
padding-top: 200px;
|
||||
padding-top: 50px;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -24,10 +25,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
h2 {
|
||||
font: 24px/28px 'Judson', sans-serif;
|
||||
font-weight: 300;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: 20px/28px 'Judson', sans-serif;
|
||||
font-weight: 300;
|
||||
margin-bottom: 20px;
|
||||
color: #d9e1e8;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: inherit;
|
||||
margin-left: 20px;
|
||||
|
||||
&[type='a'] {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
&[type='i'] {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
}
|
||||
|
||||
li > ol, li > ul {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
p, li {
|
||||
font: 20px/28px 'Judson', sans-serif;
|
||||
font-weight: 300;
|
||||
margin-bottom: 26px;
|
||||
|
||||
a {
|
||||
color: #2b90d9;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
em {
|
||||
@@ -39,6 +76,32 @@
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
|
||||
margin-bottom: 26px;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
overflow: hidden;
|
||||
|
||||
.info {
|
||||
float: right;
|
||||
text-align: right;
|
||||
line-height: 36px;
|
||||
|
||||
a {
|
||||
color: #9baec8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
.wrapper {
|
||||
padding: 20px;
|
||||
|
@@ -58,6 +58,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
margin-top: 30px;
|
||||
|
@@ -57,6 +57,43 @@ table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #42495b;
|
||||
border: 0px none #ffffff;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #525a70;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: #42495b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border: 0px none #ffffff;
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:hover {
|
||||
background: #282c37;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:active {
|
||||
background: #282c37;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: #282c37 image-url('background-photo.jpeg');
|
||||
@@ -152,173 +189,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
|
||||
.field {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.file-field {
|
||||
padding: 15px 0;
|
||||
|
||||
label {
|
||||
font-family: 'Roboto';
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
width: 100px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=text], input[type=email], input[type=password], textarea {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid #9baec8;
|
||||
padding: 7px 0;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
display: block;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
font-family: 'Roboto';
|
||||
|
||||
&:invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:focus:invalid {
|
||||
border-bottom-color: #df405a;
|
||||
}
|
||||
|
||||
&:required:valid {
|
||||
border-bottom-color: #79bd9a;
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
border-bottom-color: #2b90d9;
|
||||
}
|
||||
}
|
||||
|
||||
.field_with_error {
|
||||
input[type=text], input[type=email], input[type=password] {
|
||||
border-bottom-color: #df405a;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-size: 16px;
|
||||
color: #9baec8;
|
||||
text-align: center;
|
||||
|
||||
.prompt-highlight {
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
code.copypasteable {
|
||||
display: block;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
margin-top: 20px;
|
||||
background: #282c37;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #2b90d9;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
padding: 10px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
outline: 0;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten(#2b90d9, 5%);
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
background-color: darken(#2b90d9, 5%);
|
||||
}
|
||||
|
||||
&.negative {
|
||||
background: #df405a;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten(#df405a, 5%);
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
background-color: darken(#df405a, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: #9baec8;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #d9e1e8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#error_explanation {
|
||||
background: #282c37;
|
||||
color: #9baec8;
|
||||
border-radius: 4px;
|
||||
padding: 15px 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
|
||||
h2 {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: 15px;
|
||||
list-style: circle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-list {
|
||||
list-style: none;
|
||||
|
||||
@@ -359,6 +229,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@import 'forms';
|
||||
@import 'accounts';
|
||||
@import 'stream_entries';
|
||||
@import 'components';
|
||||
|
@@ -42,7 +42,8 @@
|
||||
|
||||
.icon-button {
|
||||
color: #616b86;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: #717b98;
|
||||
@@ -58,6 +59,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lightbox .icon-button {
|
||||
color: #282c37;
|
||||
}
|
||||
|
||||
.compose-form__textarea, .follow-form__input {
|
||||
background: #fff;
|
||||
|
||||
@@ -66,11 +71,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.emojione {
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
vertical-align: middle;
|
||||
margin: -.2ex .15em .2ex;
|
||||
|
||||
img {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.status__content, .reply-indicator__content {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
overflow: hidden;
|
||||
|
||||
.emojione {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
@@ -104,6 +126,11 @@
|
||||
.status__content {
|
||||
font-size: 19px;
|
||||
line-height: 24px;
|
||||
|
||||
.emojione {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,17 +143,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime {
|
||||
.account__header__content {
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status__display-name {
|
||||
.status__display-name, .account__display-name {
|
||||
strong {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name {
|
||||
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name, .account__display-name {
|
||||
&:hover {
|
||||
strong {
|
||||
text-decoration: underline;
|
||||
@@ -134,6 +184,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.account__display-name {
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status__display-name {
|
||||
color: #d9e1e8;
|
||||
line-height: 24px;
|
||||
@@ -197,7 +253,7 @@
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
padding: 6px 16px;
|
||||
width: 120px;
|
||||
width: 100px;
|
||||
text-decoration: none;
|
||||
background: #d9e1e8;
|
||||
color: #282c37;
|
||||
@@ -208,3 +264,89 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.static-content {
|
||||
padding: 10px;
|
||||
padding-top: 20px;
|
||||
color: #616b86;
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.columns-area {
|
||||
margin: 10px;
|
||||
margin-left: 0;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.column {
|
||||
width: 330px;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.column, .drawer {
|
||||
margin-left: 10px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.column, .drawer {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.columns-area {
|
||||
margin: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.react-autosuggest__container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestions-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestions-list {
|
||||
background: #9baec8;
|
||||
color: #282c37;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestion {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestion--focused {
|
||||
background: #2b90d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
flex: 1 1 auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
182
app/assets/stylesheets/forms.scss
Normal file
182
app/assets/stylesheets/forms.scss
Normal file
@@ -0,0 +1,182 @@
|
||||
code {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.simple_form {
|
||||
.input {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.input.file {
|
||||
padding: 15px 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
label {
|
||||
font-family: 'Roboto';
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
width: 100px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.fields-group {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.input.boolean {
|
||||
margin-bottom: 5px;
|
||||
|
||||
label {
|
||||
font-family: 'Roboto';
|
||||
font-size: 14px;
|
||||
color: #9baec8;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
display: inline-block;
|
||||
margin-bottom: -13px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=text], input[type=email], input[type=password], textarea {
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-bottom: 2px solid #9baec8;
|
||||
border-radius: 2px 2px 0 0;
|
||||
padding: 7px 4px;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
display: block;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
font-family: 'Roboto';
|
||||
|
||||
&:invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:focus:invalid {
|
||||
border-bottom-color: #df405a;
|
||||
}
|
||||
|
||||
&:required:valid {
|
||||
border-bottom-color: #79bd9a;
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
border-bottom-color: #2b90d9;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.input.field_with_errors {
|
||||
input[type=text], input[type=email], input[type=password] {
|
||||
border-bottom-color: #df405a;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-weight: 500;
|
||||
color: #df405a;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #2b90d9;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
padding: 10px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
outline: 0;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten(#2b90d9, 5%);
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
background-color: darken(#2b90d9, 5%);
|
||||
}
|
||||
|
||||
&.negative {
|
||||
background: #df405a;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten(#df405a, 5%);
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
background-color: darken(#df405a, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
background: #282c37;
|
||||
color: #9baec8;
|
||||
border-radius: 4px;
|
||||
padding: 15px 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: #9baec8;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #d9e1e8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-prompt {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
color: #9baec8;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #d9e1e8;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
@@ -46,13 +46,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.activity-stream-headless {
|
||||
.entry:first-child {
|
||||
border-radius: 4px 4px 0 0;
|
||||
.entry:first-child {
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 4px;
|
||||
}
|
||||
&:last-child {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,19 +73,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
.avatar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.entry__container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
padding: 15px;
|
||||
float: left;
|
||||
|
||||
img {
|
||||
width: 56px;
|
||||
@@ -98,7 +91,7 @@
|
||||
}
|
||||
|
||||
.entry__container__container {
|
||||
flex-grow: 1;
|
||||
margin-left: 86px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -153,10 +146,12 @@
|
||||
|
||||
.content {
|
||||
font-size: 14px;
|
||||
padding: 0 10px;
|
||||
padding: 0 15px;
|
||||
padding-left: 8px;
|
||||
padding-bottom: 15px;
|
||||
color: #282c37;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin-bottom: 18px;
|
||||
@@ -224,4 +219,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
.avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.entry__container__container {
|
||||
margin-left: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,20 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
protected
|
||||
|
||||
def hydrate_status(encoded_message)
|
||||
message = ActiveSupport::JSON.decode(encoded_message)
|
||||
|
||||
return [nil, message] if message['type'] == 'delete'
|
||||
|
||||
status = Status.find_by(id: message['id'])
|
||||
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
|
||||
|
||||
[status, message]
|
||||
end
|
||||
|
||||
def filter?(status)
|
||||
!status.nil? && FeedManager.instance.filter?(:public, status, current_user.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
11
app/channels/hashtag_channel.rb
Normal file
11
app/channels/hashtag_channel.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class HashtagChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
tag = params[:tag].downcase
|
||||
|
||||
stream_from "timeline:hashtag:#{tag}", lambda { |encoded_message|
|
||||
status, message = hydrate_status(encoded_message)
|
||||
next if filter?(status)
|
||||
transmit message
|
||||
}
|
||||
end
|
||||
end
|
9
app/channels/public_channel.rb
Normal file
9
app/channels/public_channel.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class PublicChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_from 'timeline:public', lambda { |encoded_message|
|
||||
status, message = hydrate_status(encoded_message)
|
||||
next if filter?(status)
|
||||
transmit message
|
||||
}
|
||||
end
|
||||
end
|
@@ -2,8 +2,4 @@ class TimelineChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_from "timeline:#{current_user.account_id}"
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Any cleanup needed when channel is unsubscribed
|
||||
end
|
||||
end
|
||||
|
@@ -1,5 +1,16 @@
|
||||
class AboutController < ApplicationController
|
||||
before_action :set_body_classes
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def terms
|
||||
@state = 'TBD'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'about-body'
|
||||
end
|
||||
end
|
||||
|
@@ -16,6 +16,16 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def follow
|
||||
FollowService.new.call(current_user.account, @account.acct)
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def unfollow
|
||||
UnfollowService.new.call(current_user.account, @account)
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def followers
|
||||
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6)
|
||||
end
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user