Compare commits

...

127 Commits
v0.7 ... v0.8

Author SHA1 Message Date
Eugen Rochko
f88b8ce757 Improve how errors are displayed in the UI 2016-10-18 17:09:45 +02:00
Eugen Rochko
aea151a0de Fix #97 - plain text version of devise e-mails 2016-10-18 16:39:21 +02:00
Eugen Rochko
43df35213e Improving all forms 2016-10-18 16:37:15 +02:00
Eugen Rochko
6f7c9774c7 Only run mastodon:push:clear on accounts that are still subscribed to 2016-10-18 12:23:38 +02:00
Eugen Rochko
2e0a38d07c Added e-mail edit field to settings, proper format default for webfinger 2016-10-18 03:34:26 +02:00
Eugen Rochko
d5e086a47b Adding application/jrd+json webfinger resource 2016-10-18 02:54:49 +02:00
Eugen Rochko
7bb72ff198 Small clean up 2016-10-18 01:48:46 +02:00
Eugen Rochko
b62c31306e This should fix it 2016-10-18 01:44:31 +02:00
Eugen Rochko
f8b9b0810d Debugging 2016-10-18 01:38:32 +02:00
Eugen Rochko
225ce8cfce More debugging 2016-10-18 01:34:38 +02:00
Eugen Rochko
ea44d89383 More debugging 2016-10-18 01:31:03 +02:00
Eugen Rochko
dd02fc0ec4 More debugging 2016-10-18 01:22:28 +02:00
Eugen Rochko
f3e5625d2d More debugging 2016-10-18 01:16:50 +02:00
Eugen Rochko
fdd30af595 Will this fix the issue? 2016-10-18 01:11:00 +02:00
Eugen Rochko
6611e3a2ef More debugging 2016-10-18 00:59:18 +02:00
Eugen Rochko
4baca34a45 Do not compress JS (perhaps it removes source maps, needed for debugging) 2016-10-18 00:48:40 +02:00
Eugen Rochko
564e01eaf6 Add console.error to all axios failures 2016-10-18 00:42:04 +02:00
Eugen Rochko
c9a7e6e1e3 Enable source maps 2016-10-18 00:36:01 +02:00
Eugen Rochko
6c05e3063a Include <id> (identical value to <uri>) on person-type objects in Atom
This might help with GS compatibility
2016-10-17 15:36:37 +02:00
Eugen Rochko
a782e3b39d Actually fix public timeline 2016-10-17 01:59:34 +02:00
Eugen Rochko
53deeeca01 Update ostatus2 version 2016-10-17 01:41:45 +02:00
Eugen Rochko
e865673175 Fix #82 - only show latest reblog of status in UI 2016-10-17 01:34:16 +02:00
Eugen Rochko
b5c6d00afa Fix #99 - public timeline not just reblogs, fix #98 infinite scrolling issues 2016-10-17 01:23:41 +02:00
Eugen Rochko
13ee88926d Increase default number of loaded suggestions to 10 2016-10-16 19:30:01 +02:00
Eugen Rochko
f0f791bb76 Don't preload timelines as props, load them when timeline component is mounted
This prevents the bug where if you go "back" to the UI after navigating to
another page it loads with the old set of statuses
2016-10-16 19:23:17 +02:00
Eugen Rochko
0895ff414e Fix RemoveStatusService trying to send delete salmons on behalf of remote statuses 2016-10-16 19:14:23 +02:00
Eugen Rochko
de1f3aab86 Fix #16 - Optimize n+1 queries when checking reblogged/favourited values for status lists in API 2016-10-16 19:10:16 +02:00
Eugen Rochko
1de2833f30 Filter out reblogs of blocked users from public timeline (the not real-time variant) 2016-10-16 18:35:20 +02:00
Eugen Rochko
b8eda3026f Fix scrolling on small devices for account timelines and compose column 2016-10-16 17:09:00 +02:00
Eugen Rochko
4470330385 Backfill follow suggestions with fallback when not enough results. Cycling
through suggestions in UI
2016-10-16 17:04:13 +02:00
Eugen Rochko
f9c9fef157 Maybe it will work like this 2016-10-15 18:19:35 +02:00
Eugen Rochko
07e56d52b1 Trying a travis fix 2016-10-15 18:15:47 +02:00
Eugen Rochko
6394baff4d Debugging travis 2016-10-15 18:13:23 +02:00
Eugen Rochko
2a22d4076e Trying to fix travis 2016-10-15 17:44:43 +02:00
Eugen Rochko
2993370de0 Fix travis node installation 2016-10-15 17:35:05 +02:00
Eugen Rochko
db4671fd3f Fix suggestions fallback 2016-10-15 17:34:00 +02:00
Eugen Rochko
6e0b3ddb0d Fix follow suggestions order 2016-10-15 17:19:18 +02:00
Eugen Rochko
df2c0b8dad Fix typo 2016-10-15 16:57:10 +02:00
Eugen Rochko
04bfd4262f Fix follow suggestions ranking 2016-10-15 16:54:50 +02:00
Eugen Rochko
7075cef8f9 Adding sort by node rank to follow suggestions and fallback to most influential users 2016-10-15 16:46:15 +02:00
Eugen Rochko
492a682e34 Add custom neo4j dockerfile with graphaware and noderank plugins 2016-10-15 16:13:16 +02:00
Eugen Rochko
67b35a601a Exclude blocked users from follow suggestions, fix tests 2016-10-15 13:48:38 +02:00
Eugen Rochko
aa9d48343d Fix suggestions box style for long names 2016-10-15 13:05:37 +02:00
Eugen Rochko
edefd87adf Fix multiple results return from FollowSuggestion.get 2016-10-15 13:00:21 +02:00
Eugen Rochko
70ab6624f5 Merge branch 'feature-suggestions' into development 2016-10-15 12:38:28 +02:00
Eugen Rochko
4d336cefac Add sync command for neo4j, fix configuration, add neo4j to docker-compose, fix seed 2016-10-15 12:37:43 +02:00
Eugen Rochko
20f581f796 Display follow suggestions 2016-10-15 12:06:30 +02:00
Eugen Rochko
e21a3fe0cd Adding sync of follow relationships to Neo4J, accounts/suggestions API 2016-10-14 23:10:07 +02:00
Eugen Rochko
91144d46ec Fix duplication of media attachments when a remote status reblogs a local one 2016-10-14 20:15:37 +02:00
Eugen Rochko
244d1307a3 Fix remove status service sending salmons 2016-10-14 20:09:33 +02:00
Eugen Rochko
a3384b6ea6 Add "edit profile" link to dropdown on own profile 2016-10-14 02:45:20 +02:00
Eugen Rochko
cc54683694 Update yarn lockfile 2016-10-14 02:31:13 +02:00
Eugen Rochko
ab7cb80dd5 Merge branch 'kschaper-avatar-component-testing' into development 2016-10-14 02:30:08 +02:00
Eugen Rochko
44856fb641 Merge branch 'avatar-component-testing' of https://github.com/kschaper/mastodon into kschaper-avatar-component-testing 2016-10-14 02:29:51 +02:00
Eugen Rochko
7a6d95f70c E-mail preferences page 2016-10-14 02:28:49 +02:00
Eugen Rochko
9b195f5dd3 Add window titles to auth pages and about page, link sign up/login from about page 2016-10-14 01:03:12 +02:00
Eugen Rochko
33f7e1cf99 Shorten rendered links (strip protocol and www, truncate to 30 chars), redirect
to sign in page after sign up instead of root path which redirects to /about
2016-10-13 16:51:34 +02:00
Eugen Rochko
157f03f8bd No-op for Salmons without body, fail fast if Webfinger does not contain
all required resource links (profile page, salmon, atom feed, magic key)
2016-10-13 13:41:06 +02:00
Eugen Rochko
36e7eeb6b9 Treat dfrn:owner like xmlns:author for Friendica compatibility 2016-10-12 22:55:00 +02:00
Eugen Rochko
64302b3c99 Improve Friendica support (but still not there yet) 2016-10-12 21:07:00 +02:00
Eugen Rochko
160b5148ec Fix magic key in webfinger XRD href 2016-10-12 19:30:41 +02:00
Eugen Rochko
c257b29d86 Fix up a few exceptions 2016-10-12 19:25:46 +02:00
Eugen Rochko
f7f3e6e3be Fix styles 2016-10-12 19:14:47 +02:00
Kai Schaper
cbfc12044d actually use const 2016-10-12 18:18:55 +02:00
Kai Schaper
b113cf97fb add chai-enzyme; add/improve Avatar component specs 2016-10-12 18:06:18 +02:00
Eugen Rochko
2ddf4e09f9 Change how convert options are escaped 2016-10-12 14:39:54 +02:00
Eugen Rochko
cdc49c6b4b Allow mp4 uploads, convert to WebM 2016-10-12 14:29:10 +02:00
Eugen Rochko
5e511acb82 Adjusting Dockerfile to use Yarn for faster npm installs 2016-10-12 13:48:16 +02:00
Eugen Rochko
45776b55b0 Responsively changing layout to single-column + nav on smaller screens 2016-10-12 13:17:17 +02:00
Eugen Rochko
e2ff39bf5d Merge branch 'kschaper-react-storybook' 2016-10-12 12:09:37 +02:00
Eugen Rochko
ffaba617d2 Merge in react-storybook, add yarn lockfile 2016-10-12 12:08:57 +02:00
Eugen
8d8ef18bb6 Merge pull request #89 from kschaper/react-testing
React testing
2016-10-12 12:00:36 +02:00
Kai Schaper
4eeb7947bd add stylesheet; add story for Button component 2016-10-11 20:19:03 +02:00
Kai Schaper
71e7537330 setup React Storybook; add example story for LoadingIndicator component 2016-10-11 20:19:03 +02:00
Kai Schaper
ecd4042c20 use ES6 arrow functions 2016-10-10 22:48:58 +02:00
Kai Schaper
e0a4455622 add sinon; add basic Button component test 2016-10-10 22:48:58 +02:00
Kai Schaper
998f161e1d add jsdom; add basic Avatar component test 2016-10-10 22:48:58 +02:00
Kai Schaper
1a1b9bbbc0 add required peer dependency 2016-10-10 22:48:58 +02:00
Kai Schaper
d7c55853e9 set up Mocha/Chai/Enzyme for React component unit testing 2016-10-10 22:48:58 +02:00
Eugen Rochko
77efdfa110 Fixing namespaces issue 2016-10-10 19:16:23 +02:00
Eugen Rochko
451733961b Show media of reblogged statuses in public html view 2016-10-10 18:23:08 +02:00
Eugen Rochko
68eed8c61f Stop logging incoming Atom 2016-10-10 18:16:39 +02:00
Eugen Rochko
87b618ab02 Fix namespace parsing in Atom feeds 2016-10-10 18:16:07 +02:00
Eugen Rochko
f49ed8c819 Log incoming Atom from PuSH 2016-10-10 17:40:28 +02:00
Eugen Rochko
38ce960ff9 Use Account#find_remote method when possible 2016-10-10 17:30:49 +02:00
Eugen Rochko
cfba03bd27 Don't return unsaved status from ProcessFeedService 2016-10-10 16:49:05 +02:00
Eugen Rochko
81065bc06c Adding test for ProcessFeedService 2016-10-10 16:03:38 +02:00
Eugen Rochko
3306a5d524 Improve process feed service 2016-10-10 15:27:39 +02:00
Eugen Rochko
dd5e724c3f Fix reblog mentions Atom 2016-10-10 15:08:24 +02:00
Eugen Rochko
f249a8c187 Include mentions and media attachments of reblogged posts in Atom 2016-10-10 14:22:09 +02:00
Eugen Rochko
65ae9637d6 PuSH unsubscribe needs correct callback URL 2016-10-10 03:40:08 +02:00
Eugen Rochko
aec51e40ee Make account domains case-insensitive, downcase before checking against local 2016-10-10 03:34:15 +02:00
Eugen Rochko
5f737c7228 Fix default assumed object type (note->activity), make stream entry threaded? check aware of orphaned replies 2016-10-10 02:55:30 +02:00
Eugen Rochko
0634e8dee5 Improved how in-UI profiles look 2016-10-09 22:19:15 +02:00
Eugen Rochko
29e79f770f Making AccountTimeline into a sub-route 2016-10-09 20:18:54 +02:00
Eugen Rochko
427ba27641 Public timeline to exclude users you blocked 2016-10-09 15:15:21 +02:00
Eugen Rochko
769b1ebbe0 Filter block users and reblogs of them from public timeline 2016-10-09 15:05:07 +02:00
Eugen Rochko
22a8801dbc Adding domain blocks 2016-10-09 14:48:59 +02:00
Eugen
52d7f862d3 Merge pull request #87 from vayan/feature/strip-exif-data-from-images
Strip exif data from uploaded images
2016-10-09 12:45:22 +02:00
Yann Vaillant
35de03fbe3 Strip exif data from uploaded images
Using `-strip` cli options from ImageMagick

http://www.imagemagick.org/script/command-line-options.php#strip

fix #85
2016-10-08 15:18:20 +02:00
Eugen Rochko
f40843d680 Fix travis test 2016-10-08 00:48:36 +02:00
Eugen Rochko
d85df27053 Fix mistake 2016-10-08 00:39:46 +02:00
Eugen Rochko
bef4d8dab8 Add link to public timeline to getting started screen 2016-10-08 00:30:56 +02:00
Eugen Rochko
b0168c8f3c Install npm 3 in docker (v3 warns on invalid peer dependencies instead of failing) 2016-10-08 00:23:01 +02:00
Eugen Rochko
ef9d4f4e06 Use reselect to memoize denormalization in UI state
Also upgrade react-redux to latest version. This is a performance update
2016-10-08 00:01:22 +02:00
Eugen Rochko
1f650d327d Adding public timeline 2016-10-07 16:00:11 +02:00
Eugen Rochko
06016453bd Adding user settings (model and mailer), no form yet 2016-10-07 13:17:56 +02:00
Eugen Rochko
943c69c65d Fix #73 - Click on in-UI profile avatar/name to open public profile 2016-10-06 23:15:50 +02:00
Eugen Rochko
a4b8069cf5 Styling loading indicator, removing unused routes, adding "getting started" explanation
Also, only update relative time every minute instead of 6 seconds. My bad
2016-10-06 22:47:35 +02:00
Eugen Rochko
e8a8703a4b Fix how missing display name is displayed (fallback to username) 2016-10-06 22:07:32 +02:00
Eugen Rochko
296ce2d45a Adjusting new CSS for smaller screens 2016-10-06 22:00:56 +02:00
Eugen Rochko
2af3abd279 Fix #79 - break too long lines and hide overflowing characters in status content 2016-10-06 21:56:07 +02:00
Eugen Rochko
05af66d6b9 Fix #80 - pretty scrollbars for lucky webkit users 2016-10-06 21:39:30 +02:00
Eugen Rochko
d772db4344 Fix #83 - if user cannot be found (or is self) throw error, don't return empty 200
This prevents the undefined profile from being opened in the first place on such an error
2016-10-06 21:33:33 +02:00
Eugen Rochko
3554d638b3 Fix #72 - add follow/unfollow button to public profiles 2016-10-06 21:27:58 +02:00
Eugen Rochko
87ba52ad3f Fix delete option missing from statuses in detailed view 2016-10-06 17:27:07 +02:00
Eugen Rochko
15d01a5e08 Better comparison of "local" domain 2016-10-06 16:36:16 +02:00
Eugen Rochko
b304cc07d5 Fix #76 - set scrollTop property of element node rather than use scrollTo() method 2016-10-06 16:18:32 +02:00
Eugen Rochko
b60430fe8f Fix sign-in redirecting "back" to a missing image because missing static files hit the raise_not_found method 2016-10-06 15:42:00 +02:00
Eugen Rochko
8bdbe99d69 Fix #71 - Add logout link to UI 2016-10-06 15:34:11 +02:00
Eugen Rochko
68402228f3 Fix #70 - disable autocomplete on follow form 2016-10-06 15:24:28 +02:00
Eugen Rochko
5cfc9efad3 Update OStatus2 to handle malformed Salmon without raising unexpected exceptions 2016-10-06 14:47:38 +02:00
Eugen Rochko
2f5b205916 Catch Paperclip errors on /api/v1/media, return early from update profile service if XML given is nil 2016-10-06 14:40:15 +02:00
Eugen
cdad5d322d Merge pull request #74 from waldyrious/patch-1
Use consistent capitalization for subject lines
2016-10-06 12:26:21 +02:00
Waldir Pimenta
4f654eb822 use consistent capitalization for subject lines 2016-10-06 11:16:59 +01:00
Eugen
3298c7e1c8 Merge pull request #69 from vayan/feature/change-wording-in-mailer-view
Update default devise wording to include reference to Mastodon
2016-10-06 09:59:00 +02:00
Yann Vaillant
038a407b9e Update default devise wording to include reference to Mastodon
Fix #68
2016-10-06 07:45:07 +02:00
167 changed files with 7359 additions and 822 deletions

View File

@@ -3,3 +3,5 @@
public/system public/system
public/assets public/assets
node_modules node_modules
storybook
neo4j

View File

@@ -6,6 +6,8 @@ DB_USER=postgres
DB_NAME=postgres DB_NAME=postgres
DB_PASS= DB_PASS=
DB_PORT=5432 DB_PORT=5432
NEO4J_HOST=neo4j
NEO4J_PORT=7474
# Federation # Federation
LOCAL_DOMAIN=example.com LOCAL_DOMAIN=example.com

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ public/assets
.env .env
.env.* .env.*
node_modules/ node_modules/
neo4j/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
6.7.0

View File

@@ -1,11 +1,18 @@
language: ruby language: ruby
cache: bundler cache: bundler
notifications:
email: false
env: env:
matrix:
- TRAVIS_NODE_VERSION="4"
global: global:
- LOCAL_DOMAIN=cb6e6126.ngrok.io - LOCAL_DOMAIN=cb6e6126.ngrok.io
- LOCAL_HTTPS=true - LOCAL_HTTPS=true
- RAILS_ENV=test - RAILS_ENV=test
- NEO4J_HOST=localhost
- NEO4J_PORT=7575
addons: addons:
postgresql: 9.4 postgresql: 9.4
@@ -19,11 +26,15 @@ services:
bundler_args: --without development production --retry=3 --jobs=3 bundler_args: --without development production --retry=3 --jobs=3
install: install:
- npm install -g npm@2 - nvm install $TRAVIS_NODE_VERSION
- npm install - npm install -g npm@3
- npm install -g yarn
- bundle install - bundle install
- yarn install
before_script: before_script:
- bundle exec rails db:create db:migrate - bundle exec rails db:create db:migrate
script: bundle exec rspec script:
- bundle exec rspec
- npm test

View File

@@ -3,7 +3,9 @@ FROM ruby:2.2.4
ENV RAILS_ENV=production ENV RAILS_ENV=production
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list 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 RUN mkdir /mastodon
WORKDIR /mastodon WORKDIR /mastodon
@@ -13,7 +15,8 @@ ADD Gemfile.lock /mastodon/Gemfile.lock
RUN bundle install --deployment --without test development RUN bundle install --deployment --without test development
ADD package.json /mastodon/package.json ADD package.json /mastodon/package.json
RUN npm install ADD yarn.lock /mastodon/yarn.lock
RUN yarn
ADD . /mastodon ADD . /mastodon

17
Dockerfile.neo4j Normal file
View 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

View File

@@ -38,6 +38,8 @@ gem 'simple_form'
gem 'will_paginate' gem 'will_paginate'
gem 'rack-attack' gem 'rack-attack'
gem 'sidekiq' gem 'sidekiq'
gem 'ledermann-rails-settings'
gem 'neography'
gem 'react-rails' gem 'react-rails'
gem 'browserify-rails' gem 'browserify-rails'

View File

@@ -97,6 +97,7 @@ GEM
dotenv (= 2.1.1) dotenv (= 2.1.1)
railties (>= 4.0, < 5.1) railties (>= 4.0, < 5.1)
erubis (2.7.0) erubis (2.7.0)
excon (0.53.0)
execjs (2.7.0) execjs (2.7.0)
fabrication (2.15.2) fabrication (2.15.2)
fast_blank (1.0.0) fast_blank (1.0.0)
@@ -107,7 +108,7 @@ GEM
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (0.3.7) globalid (0.3.7)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
goldfinger (1.0.5) goldfinger (1.1.0)
addressable (~> 2.4) addressable (~> 2.4)
http (~> 2.0) http (~> 2.0)
nokogiri (~> 1.6) nokogiri (~> 1.6)
@@ -145,6 +146,8 @@ GEM
json (1.8.3) json (1.8.3)
launchy (2.4.3) launchy (2.4.3)
addressable (~> 2.3) addressable (~> 2.3)
ledermann-rails-settings (2.4.2)
activerecord (>= 3.1)
letter_opener (1.4.1) letter_opener (1.4.1)
launchy (~> 2.2) launchy (~> 2.2)
libv8 (3.16.14.15) libv8 (3.16.14.15)
@@ -163,15 +166,22 @@ GEM
mime-types-data (3.2016.0521) mime-types-data (3.2016.0521)
mimemagic (0.3.0) mimemagic (0.3.0)
mini_portile2 (2.1.0) mini_portile2 (2.1.0)
minitest (5.9.0) minitest (5.9.1)
multi_json (1.12.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) nio4r (1.2.1)
nokogiri (1.6.8) nokogiri (1.6.8.1)
mini_portile2 (~> 2.1.0) mini_portile2 (~> 2.1.0)
pkg-config (~> 1.1.7)
oj (2.17.3) oj (2.17.3)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostatus2 (0.3.1) os (0.9.6)
ostatus2 (1.0.2)
addressable (~> 2.4) addressable (~> 2.4)
http (~> 2.0) http (~> 2.0)
nokogiri (~> 1.6) nokogiri (~> 1.6)
@@ -187,7 +197,6 @@ GEM
parser (2.3.1.2) parser (2.3.1.2)
ast (~> 2.2) ast (~> 2.2)
pg (0.18.4) pg (0.18.4)
pkg-config (1.1.7)
powerpack (0.1.1) powerpack (0.1.1)
pry (0.10.4) pry (0.10.4)
coderay (~> 1.1.0) coderay (~> 1.1.0)
@@ -236,7 +245,7 @@ GEM
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rainbow (2.1.0) rainbow (2.1.0)
rake (11.2.2) rake (11.3.0)
rdoc (4.2.2) rdoc (4.2.2)
json (~> 1.4) json (~> 1.4)
react-rails (1.8.2) react-rails (1.8.2)
@@ -281,6 +290,7 @@ GEM
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1) unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.8.1) ruby-progressbar (1.8.1)
rubyzip (1.2.0)
safe_yaml (1.0.4) safe_yaml (1.0.4)
sass (3.4.22) sass (3.4.22)
sass-rails (5.0.6) sass-rails (5.0.6)
@@ -366,9 +376,11 @@ DEPENDENCIES
httplog httplog
jbuilder (~> 2.0) jbuilder (~> 2.0)
jquery-rails jquery-rails
ledermann-rails-settings
letter_opener letter_opener
link_header link_header
lograge lograge
neography
nokogiri nokogiri
oj oj
ostatus2 ostatus2

View File

@@ -57,6 +57,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
- PostgreSQL - PostgreSQL
- Redis - Redis
- Neo4J (optional)
- GraphAware NodeRank
## Running with Docker and Docker-Compose ## Running with Docker and Docker-Compose
@@ -86,6 +88,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: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: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: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: Running any of these tasks via docker-compose would look like this:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,2 @@
//= require jquery
//= require jquery_ujs

View File

@@ -3,6 +3,7 @@
window.React = require('react'); window.React = require('react');
window.ReactDOM = require('react-dom'); window.ReactDOM = require('react-dom');
window.Perf = require('react-addons-perf');
//= require_tree ./components //= require_tree ./components

View File

@@ -1,5 +1,6 @@
import api from '../api' import api from '../api'
import axios from 'axios'; import axios from 'axios';
import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
@@ -66,7 +67,7 @@ export function fetchAccountTimeline(id) {
export function expandAccountTimeline(id) { export function expandAccountTimeline(id) {
return (dispatch, getState) => { 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)); dispatch(expandAccountTimelineRequest(id));

View File

@@ -1,3 +1,4 @@
export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW';
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS'; export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR'; export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
@@ -13,3 +14,11 @@ export function clearNotifications() {
type: NOTIFICATION_CLEAR type: NOTIFICATION_CLEAR
}; };
}; };
export function showNotification(title, message) {
return {
type: NOTIFICATION_SHOW,
title: title,
message: message
};
};

View 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(suggestions) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
suggestions: suggestions
};
};
export function fetchSuggestionsFail(error) {
return {
type: SUGGESTIONS_FETCH_FAIL,
error: error
};
};

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ const RelativeTimestamp = React.createClass({
componentWillMount () { componentWillMount () {
this._updateMomentText(); this._updateMomentText();
this.interval = setInterval(this._updateMomentText, 6000); this.interval = setInterval(this._updateMomentText, 60000);
}, },
componentWillUnmount () { componentWillUnmount () {

View File

@@ -41,16 +41,21 @@ const Status = React.createClass({
}, },
render () { render () {
var media = ''; let media = '';
let { status, ...other } = this.props;
var { status, ...other } = this.props; 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 ( return (
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}> <div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}> <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> <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> </div>
<Status {...other} wrapped={true} status={status.get('reblog')} /> <Status {...other} wrapped={true} status={status.get('reblog')} />

View File

@@ -1,7 +1,7 @@
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button'; import IconButton from './icon_button';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; import DropdownMenu from './dropdown_menu';
const StatusActionBar = React.createClass({ const StatusActionBar = React.createClass({
propTypes: { propTypes: {
@@ -26,23 +26,16 @@ const StatusActionBar = React.createClass({
this.props.onReblog(this.props.status); this.props.onReblog(this.props.status);
}, },
handleDeleteClick(e) { handleDeleteClick () {
e.preventDefault();
this.props.onDelete(this.props.status); this.props.onDelete(this.props.status);
}, },
render () { render () {
const { status, me } = this.props; const { status, me } = this.props;
let menu = ''; let menu = [];
if (status.getIn(['account', 'id']) === me) { if (status.getIn(['account', 'id']) === me) {
menu = ( menu.push({ text: 'Delete', action: this.handleDeleteClick });
<ul>
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
</ul>
);
} else {
menu = <ul />;
} }
return ( return (
@@ -52,13 +45,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 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' }}> <div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
<Dropdown> <DropdownMenu items={menu} icon='ellipsis-h' size={18} />
<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>
</div> </div>
</div> </div>
); );

View File

@@ -22,11 +22,11 @@ const StatusContent = React.createClass({
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) { if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention)); link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
} else { } else {
link.setAttribute('target', '_blank'); link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener'); link.setAttribute('rel', 'noopener');
link.addEventListener('click', this.onNormalClick.bind(this)); link.addEventListener('click', this.onNormalClick, false);
} }
} }
}, },

View File

@@ -28,7 +28,7 @@ const StatusList = React.createClass({
const { statuses, onScrollToBottom, ...other } = this.props; const { statuses, onScrollToBottom, ...other } = this.props;
return ( return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}> <div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}>
<div> <div>
{statuses.map((status) => { {statuses.map((status) => {
return <Status key={status.get('id')} {...other} status={status} />; return <Status key={status.get('id')} {...other} status={status} />;

View File

@@ -1,20 +1,29 @@
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import configureStore from '../store/configureStore'; import configureStore from '../store/configureStore';
import { import {
refreshTimelineSuccess, refreshTimelineSuccess,
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
refreshTimeline refreshTimeline
} from '../actions/timelines'; } from '../actions/timelines';
import { setAccessToken } from '../actions/meta'; import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts'; import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Router, Route, hashHistory } from 'react-router'; import {
import Account from '../features/account'; Router,
import Settings from '../features/settings'; Route,
import Status from '../features/status'; hashHistory,
import Subscriptions from '../features/subscriptions'; IndexRoute
import UI from '../features/ui'; } from 'react-router';
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';
const store = configureStore(); const store = configureStore();
@@ -32,21 +41,8 @@ const Mastodon = React.createClass({
store.dispatch(setAccessToken(this.props.token)); store.dispatch(setAccessToken(this.props.token));
store.dispatch(setAccountSelf(JSON.parse(this.props.account))); 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') { if (typeof App !== 'undefined') {
App.timeline = App.cable.subscriptions.create("TimelineChannel", { this.subscription = App.cable.subscriptions.create('TimelineChannel', {
connected () {
},
disconnected () {
},
received (data) { received (data) {
switch(data.type) { switch(data.type) {
@@ -61,19 +57,31 @@ const Mastodon = React.createClass({
return store.dispatch(refreshTimeline('mentions')); return store.dispatch(refreshTimeline('mentions'));
} }
} }
}); });
} }
}, },
componentWillUnmount () {
if (typeof this.subscription !== 'undefined') {
this.subscription.unsubscribe();
}
},
render () { render () {
return ( return (
<Provider store={store}> <Provider store={store}>
<Router history={hashHistory}> <Router history={hashHistory}>
<Route path='/' component={UI}> <Route path='/' component={UI}>
<Route path='/settings' component={Settings} /> <IndexRoute component={GettingStarted} />
<Route path='/subscriptions' component={Subscriptions} /> <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/:statusId' component={Status} /> <Route path='/statuses/:statusId' component={Status} />
<Route path='/accounts/:accountId' component={Account} /> <Route path='/accounts/:accountId' component={Account}>
<IndexRoute component={AccountTimeline} />
</Route>
</Route> </Route>
</Router> </Router>
</Provider> </Provider>

View File

@@ -1,6 +1,6 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Button from '../../../components/button'; import DropdownMenu from '../../../components/dropdown_menu';
const ActionBar = React.createClass({ const ActionBar = React.createClass({
@@ -16,47 +16,42 @@ const ActionBar = React.createClass({
render () { render () {
const { account, me } = this.props; const { account, me } = this.props;
let infoText = ''; let menu = [];
let follow = '';
let buttonText = '';
let block = '';
let disabled = false;
if (account.get('id') === me) { if (account.get('id') === me) {
buttonText = 'This is you!'; menu.push({ text: 'Edit profile', href: '/settings/profile' });
disabled = true; } 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 { } else {
let blockText = ''; menu.push({ text: 'Follow', action: this.props.onFollow });
menu.push({ text: 'Block', action: this.props.onBlock });
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} />;
} }
return ( return (
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}> <div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
{follow} {block} <div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span> <div style={{ overflow: 'hidden', width: '80px', borderRight: '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>
</div>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '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>
</div>
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '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>
</div>
</div>
<div style={{ padding: '10px', flex: '1 1 auto' }}>
<DropdownMenu items={menu} icon='bars' size={24} />
</div>
</div> </div>
); );
}, },

View File

@@ -4,24 +4,41 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const Header = React.createClass({ const Header = React.createClass({
propTypes: { propTypes: {
account: ImmutablePropTypes.map.isRequired account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
render () { 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>
}
return ( return (
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover' }}> <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: '30px 10px' }}> <div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}> <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} /> <div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
</div> <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div>
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
</a>
<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> <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> <p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
{info}
</div> </div>
</div> </div>
); );

View File

@@ -10,30 +10,17 @@ import {
fetchAccountTimeline, fetchAccountTimeline,
expandAccountTimeline expandAccountTimeline
} from '../../actions/accounts'; } from '../../actions/accounts';
import { deleteStatus } from '../../actions/statuses';
import { replyCompose } from '../../actions/compose';
import {
favourite,
reblog,
unreblog,
unfavourite
} from '../../actions/interactions';
import Header from './components/header'; import Header from './components/header';
import { import {
selectStatus, getAccountTimeline,
selectAccount getAccount
} from '../../reducers/timelines'; } from '../../selectors';
import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator';
import Immutable from 'immutable';
import ActionBar from './components/action_bar'; import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
function selectStatuses(state, accountId) {
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null);
};
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
account: selectAccount(state, Number(props.params.accountId)), account: getAccount(state, Number(props.params.accountId)),
statuses: selectStatuses(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me']) me: state.getIn(['timelines', 'me'])
}); });
@@ -43,20 +30,18 @@ const Account = React.createClass({
params: React.PropTypes.object.isRequired, params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
statuses: ImmutablePropTypes.list me: React.PropTypes.number.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
componentWillMount () { componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); 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) { if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
} }
}, },
@@ -76,47 +61,25 @@ 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')));
},
render () { render () {
const { account, statuses, me } = this.props; const { account, me } = this.props;
if (account === null) { if (account === null) {
return <div>Loading {this.props.params.accountId}...</div>; return (
<Column>
<LoadingIndicator />
</Column>
);
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}> <Column>
<Header account={account} /> <Header account={account} me={me} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} /> <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> {this.props.children}
</Column>
); );
} }

View File

@@ -0,0 +1,80 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getAccountTimeline } from '../../selectors';
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 StatusList from '../../components/status_list';
const mapStateToProps = (state, props) => ({
statuses: getAccountTimeline(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
});
const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statuses: 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)));
}
},
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(Number(this.props.params.accountId)));
},
render () {
const { statuses, me } = this.props;
return <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} onDelete={this.handleDelete} />
}
});
export default connect(mapStateToProps)(AccountTimeline);

View File

@@ -0,0 +1,123 @@
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';
const outerStyle = {
marginBottom: '10px',
borderTop: '1px solid #616b86',
position: 'relative'
};
const headerStyle = {
fontSize: '14px',
fontWeight: '500',
display: 'block',
padding: '10px',
color: '#9baec8',
background: '#454b5e',
width: '120px',
marginTop: '-18px'
};
const itemStyle = {
display: 'block',
padding: '10px',
color: '#9baec8',
overflow: 'hidden',
textDecoration: 'none'
};
const displayNameStyle = {
display: 'block',
fontWeight: '500',
overflow: 'hidden',
textOverflow: 'ellipsis'
};
const acctStyle = {
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis'
};
const nextStyle = {
fontWeight: '400',
color: '#2b90d9'
};
const SuggestionsBox = React.createClass({
propTypes: {
accounts: ImmutablePropTypes.list.isRequired,
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.accounts.skip(this.props.perWindow * newIndex).size === 0) {
newIndex = 0;
}
this.setState({ index: newIndex });
},
render () {
const { accounts, perWindow } = this.props;
if (accounts.size === 0) {
return <div />;
}
let nextLink = '';
if (accounts.size > perWindow) {
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Next</a>;
}
return (
<div style={outerStyle}>
<strong style={headerStyle}>
Who to follow {nextLink}
</strong>
{accounts.skip(perWindow * this.state.index).take(perWindow).map(account => {
let displayName = account.get('display_name');
if (displayName.length === 0) {
displayName = account.get('username');
}
return (
<Link key={account.get('id')} style={itemStyle} to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
<strong style={displayNameStyle}>{displayName}</strong>
<span style={acctStyle}>{account.get('acct')}</span>
</Link>
)
})}
</div>
);
}
});
export default SuggestionsBox;

View File

@@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { getSuggestions } from '../../../selectors';
import SuggestionsBox from '../components/suggestions_box';
const mapStateToProps = (state) => ({
accounts: getSuggestions(state)
});
export default connect(mapStateToProps)(SuggestionsBox);

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

View File

@@ -0,0 +1,19 @@
import Column from '../ui/components/column';
import { Link } from 'react-router';
const GettingStarted = () => {
return (
<Column>
<div className='static-content'>
<h1>Getting started</h1>
<p>Mastodon is still in development and one of the lacking areas at the moment is user discovery.</p>
<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;

View File

@@ -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 type='home' />
</Column>
);
},
});
export default connect()(HomeTimeline);

View File

@@ -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 type='mentions' />
</Column>
);
},
});
export default connect()(MentionsTimeline);

View File

@@ -0,0 +1,50 @@
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
} 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) {
dispatch(updateTimeline('public', JSON.parse(data.message)));
}
});
}
},
componentWillUnmount () {
if (typeof this.subscription !== 'undefined') {
this.subscription.unsubscribe();
}
},
render () {
return (
<Column icon='globe' heading='Public'>
<StatusListContainer type='public' />
</Column>
);
},
});
export default connect()(PublicTimeline);

View File

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

View File

@@ -1,26 +1,36 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenu from '../../../components/dropdown_menu';
const ActionBar = React.createClass({ const ActionBar = React.createClass({
propTypes: { propTypes: {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
onReply: React.PropTypes.func.isRequired, onReply: React.PropTypes.func.isRequired,
onReblog: React.PropTypes.func.isRequired, onReblog: React.PropTypes.func.isRequired,
onFavourite: React.PropTypes.func.isRequired onFavourite: React.PropTypes.func.isRequired,
onDelete: React.PropTypes.func.isRequired,
me: React.PropTypes.number.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
render () { 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) });
}
return ( return (
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}> <div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div> <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('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' }}><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> </div>
); );
} }

View File

@@ -4,20 +4,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses'; import { fetchStatus } from '../../actions/statuses';
import Immutable from 'immutable'; import Immutable from 'immutable';
import EmbeddedStatus from '../../components/status'; import EmbeddedStatus from '../../components/status';
import LoadingIndicator from '../../components/loading_indicator';
import DetailedStatus from './components/detailed_status'; import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar'; import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import { favourite, reblog } from '../../actions/interactions'; import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose'; import { replyCompose } from '../../actions/compose';
import { selectStatus } from '../../reducers/timelines'; import { deleteStatus } from '../../actions/statuses';
import {
function selectStatuses(state, ids) { getStatus,
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null); getStatusAncestors,
}; getStatusDescendants
} from '../../selectors';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
status: selectStatus(state, Number(props.params.statusId)), status: getStatus(state, Number(props.params.statusId)),
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.OrderedSet())), ancestors: getStatusAncestors(state, Number(props.params.statusId)),
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.OrderedSet())) descendants: getStatusDescendants(state, Number(props.params.statusId)),
me: state.getIn(['timelines', 'me'])
}); });
const Status = React.createClass({ const Status = React.createClass({
@@ -54,28 +58,38 @@ const Status = React.createClass({
this.props.dispatch(reblog(status)); this.props.dispatch(reblog(status));
}, },
handleDeleteClick (status) {
this.props.dispatch(deleteStatus(status.get('id')));
},
renderChildren (list) { 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(s => <EmbeddedStatus status={s} me={this.props.me} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />);
}, },
render () { render () {
const { status, ancestors, descendants } = this.props; const { status, ancestors, descendants, me } = this.props;
if (status === null) { if (status === null) {
return <div>Loading {this.props.params.statusId}...</div>; return (
<Column>
<LoadingIndicator />
</Column>
);
} }
const account = status.get('account'); const account = status.get('account');
return ( return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'> <Column>
<div>{this.renderChildren(ancestors)}</div> <div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
<div>{this.renderChildren(ancestors)}</div>
<DetailedStatus status={status} /> <DetailedStatus status={status} me={me} />
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} /> <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />
<div>{this.renderChildren(descendants)}</div> <div>{this.renderChildren(descendants)}</div>
</div> </div>
</Column>
); );
} }

View File

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

View File

@@ -18,7 +18,7 @@ const scrollTop = (node) => {
return; return;
} }
node.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration)); node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
requestAnimationFrame(step); requestAnimationFrame(step);
}; };
@@ -29,6 +29,12 @@ const scrollTop = (node) => {
}; };
}; };
const style = {
boxSizing: 'border-box',
background: '#282c37',
display: 'flex',
flexDirection: 'column'
};
const Column = React.createClass({ const Column = React.createClass({
@@ -50,10 +56,6 @@ const Column = React.createClass({
} }
}, },
handleScroll () {
// todo
},
render () { render () {
let header = ''; let header = '';
@@ -61,10 +63,8 @@ const Column = React.createClass({
header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />; 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 ( return (
<div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}> <div className='column' style={style} onWheel={this.handleWheel}>
{header} {header}
{this.props.children} {this.props.children}
</div> </div>

View File

@@ -1,12 +1,19 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; 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({ const ColumnsArea = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
render () { render () {
return ( return (
<div style={{ display: 'flex', flexDirection: 'row', flex: '1', marginRight: '10px', marginBottom: '10px', overflowX: 'auto' }}> <div className='columns-area' style={style}>
{this.props.children} {this.props.children}
</div> </div>
); );

View File

@@ -1,12 +1,21 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; 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({ const Drawer = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
render () { render () {
return ( 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} {this.props.children}
</div> </div>
); );

View File

@@ -33,7 +33,7 @@ const FollowForm = React.createClass({
render () { render () {
return ( return (
<div style={{ display: 'flex', lineHeight: '20px', padding: '10px', background: '#373b4a' }}> <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 style={{ padding: '10px', paddingRight: '0' }}><IconButton title='Follow' size={20} icon='user-plus' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
</div> </div>
); );

View File

@@ -17,9 +17,9 @@ const NavigationBar = React.createClass({
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}> <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> <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' }}> <div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong> <strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
<a href='/settings' style={{ color: '#9baec8', textDecoration: 'none' }}>Settings <i className='fa fa fa-cog' /></a> <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>
</div> </div>
); );

View File

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

View File

@@ -1,14 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form'; import ComposeForm from '../components/compose_form';
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose'; import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
import { selectStatus } from '../../../reducers/timelines'; import { getStatus } from '../../../selectors';
const mapStateToProps = function (state, props) { const mapStateToProps = function (state, props) {
return { return {
text: state.getIn(['compose', 'text']), text: state.getIn(['compose', 'text']),
is_submitting: state.getIn(['compose', 'is_submitting']), is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']), is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to'])) in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
}; };
}; };

View File

@@ -4,14 +4,10 @@ import {
dismissNotification, dismissNotification,
clearNotifications clearNotifications
} from '../../../actions/notifications'; } from '../../../actions/notifications';
import { getNotifications } from '../../../selectors';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
notifications: state.get('notifications').map((item, i) => ({ notifications: getNotifications(state)
message: item.get('message'),
title: item.get('title'),
key: item.get('key'),
dismissAfter: 5000
})).toJS()
}); });
const mapDispatchToProps = (dispatch) => { const mapDispatchToProps = (dispatch) => {

View File

@@ -8,14 +8,18 @@ import {
unfavourite unfavourite
} from '../../../actions/interactions'; } from '../../../actions/interactions';
import { expandTimeline } from '../../../actions/timelines'; import { expandTimeline } from '../../../actions/timelines';
import { selectStatus } from '../../../reducers/timelines'; import { makeGetTimeline } from '../../../selectors';
import { deleteStatus } from '../../../actions/statuses'; import { deleteStatus } from '../../../actions/statuses';
const mapStateToProps = function (state, props) { const makeMapStateToProps = () => {
return { const getTimeline = makeGetTimeline();
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
const mapStateToProps = (state, props) => ({
statuses: getTimeline(state, props.type),
me: state.getIn(['timelines', 'me']) me: state.getIn(['timelines', 'me'])
}; });
return mapStateToProps;
}; };
const mapDispatchToProps = function (dispatch, props) { const mapDispatchToProps = function (dispatch, props) {
@@ -50,4 +54,4 @@ const mapDispatchToProps = function (dispatch, props) {
}; };
}; };
export default connect(mapStateToProps, mapDispatchToProps)(StatusList); export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);

View File

@@ -1,49 +1,38 @@
import ColumnsArea from './components/columns_area'; 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 NotificationsContainer from './containers/notifications_container';
import NavigationContainer from './containers/navigation_container';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import LoadingBarContainer from './containers/loading_bar_container'; 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';
const UI = React.createClass({ const UI = React.createClass({
propTypes: {
router: React.PropTypes.object
},
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
render () { render () {
const layoutBreakpoint = 1024;
return ( return (
<div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}> <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', width: '100%', height: '100%', background: '#1a1c23' }}>
<Drawer> <MediaQuery maxWidth={layoutBreakpoint}>
<div style={{ flex: '1 1 auto' }}> <TabsBar />
<NavigationContainer /> </MediaQuery>
<ComposeFormContainer />
<UploadFormContainer />
</div>
<FollowFormContainer /> <MediaQuery maxWidth={layoutBreakpoint} component={ColumnsArea}>
</Drawer> {this.props.children}
</MediaQuery>
<ColumnsArea> <MediaQuery minWidth={layoutBreakpoint}>
<Column icon='home' heading='Home'> <ColumnsArea>
<StatusListContainer type='home' /> <Compose />
</Column> <HomeTimeline />
<MentionsTimeline />
<Column icon='at' heading='Mentions'>
<StatusListContainer type='mentions' />
</Column>
<Column>
{this.props.children} {this.props.children}
</Column> </ColumnsArea>
</ColumnsArea> </MediaQuery>
<NotificationsContainer /> <NotificationsContainer />
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} /> <LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />

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

View File

@@ -1,68 +1,20 @@
import { COMPOSE_SUBMIT_FAIL, COMPOSE_UPLOAD_FAIL } from '../actions/compose';
import { FOLLOW_SUBMIT_FAIL } from '../actions/follow';
import { import {
REBLOG_FAIL, NOTIFICATION_SHOW,
UNREBLOG_FAIL, NOTIFICATION_DISMISS,
FAVOURITE_FAIL, NOTIFICATION_CLEAR
UNFAVOURITE_FAIL } from '../actions/notifications';
} from '../actions/interactions'; import Immutable from 'immutable';
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';
const initialState = Immutable.List(); 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);
};
export default function notifications(state = initialState, action) { export default function notifications(state = initialState, action) {
switch(action.type) { switch(action.type) {
case COMPOSE_SUBMIT_FAIL: case NOTIFICATION_SHOW:
case COMPOSE_UPLOAD_FAIL: return state.push(Immutable.Map({
case FOLLOW_SUBMIT_FAIL: key: state.size > 0 ? state.last().get('key') + 1 : 0,
case REBLOG_FAIL: title: action.title,
case FAVOURITE_FAIL: message: action.message
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_DISMISS: case NOTIFICATION_DISMISS:
return state.filterNot(item => item.get('key') === action.notification.key); return state.filterNot(item => item.get('key') === action.notification.key);
case NOTIFICATION_CLEAR: case NOTIFICATION_CLEAR:

View File

@@ -25,53 +25,30 @@ import {
STATUS_DELETE_SUCCESS STATUS_DELETE_SUCCESS
} from '../actions/statuses'; } from '../actions/statuses';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow'; import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
home: Immutable.List([]), home: Immutable.List([]),
mentions: Immutable.List([]), mentions: Immutable.List([]),
public: Immutable.List([]),
statuses: Immutable.Map(), statuses: Immutable.Map(),
accounts: Immutable.Map(), accounts: Immutable.Map(),
accounts_timelines: Immutable.Map(), accounts_timelines: Immutable.Map(),
me: null, me: null,
ancestors: Immutable.Map(), ancestors: Immutable.Map(),
descendants: Immutable.Map(), descendants: Immutable.Map(),
relationships: Immutable.Map() relationships: Immutable.Map(),
suggestions: Immutable.List([])
}); });
export function selectStatus(state, id) {
let status = state.getIn(['timelines', 'statuses', id], null);
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) { function normalizeStatus(state, status) {
// Separate account // Separate account
let account = status.get('account'); let account = status.get('account');
status = status.set('account', account.get('id')); status = status.set('account', account.get('id'));
// Separate reblog, repeat for reblog // Separate reblog, repeat for reblog
let reblog = status.get('reblog'); let reblog = status.get('reblog', null);
if (reblog !== null) { if (reblog !== null) {
status = status.set('reblog', reblog.get('id')); status = status.set('reblog', reblog.get('id'));
@@ -101,16 +78,18 @@ function normalizeStatus(state, status) {
}; };
function normalizeTimeline(state, timeline, statuses) { function normalizeTimeline(state, timeline, statuses) {
let ids = Immutable.List([]);
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
state = state.setIn([timeline, i], status.get('id')); ids = ids.set(i, status.get('id'));
}); });
return state; return state.set(timeline, ids);
}; };
function appendNormalizedTimeline(state, timeline, statuses) { function appendNormalizedTimeline(state, timeline, statuses) {
let moreIds = Immutable.List(); let moreIds = Immutable.List([]);
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
@@ -121,29 +100,44 @@ function appendNormalizedTimeline(state, timeline, statuses) {
}; };
function normalizeAccountTimeline(state, accountId, statuses) { function normalizeAccountTimeline(state, accountId, statuses) {
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => {
return (list.size > 0) ? list.clear() : list;
});
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
state = state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.set(i, status.get('id'))); state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.set(i, status.get('id')));
}); });
return state; return state;
}; };
function appendNormalizedAccountTimeline(state, accountId, statuses) { function appendNormalizedAccountTimeline(state, accountId, statuses) {
let moreIds = Immutable.List(); let moreIds = Immutable.List([]);
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id')); moreIds = moreIds.set(i, status.get('id'));
}); });
return state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.push(...moreIds)); return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
}; };
function updateTimeline(state, timeline, status) { function updateTimeline(state, timeline, status) {
state = normalizeStatus(state, 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'))); state = state.update(timeline, list => {
const reblogOfId = status.getIn(['reblog', 'id'], null);
if (reblogOfId !== null) {
const otherReblogs = state.get('statuses').filter(item => item.get('reblog') === reblogOfId).map((_, itemId) => itemId);
list = list.filterNot(itemId => (itemId === reblogOfId || otherReblogs.includes(itemId)));
}
return list.unshift(status.get('id'));
});
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List([]), list => (list.includes(status.get('id')) ? list : list.unshift(status.get('id'))));
return state; return state;
}; };
@@ -161,7 +155,7 @@ function deleteStatus(state, id) {
}); });
// Remove references from account timelines // 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', status.get('account')], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove reblogs of deleted status // Remove reblogs of deleted status
const references = state.get('statuses').filter(item => item.get('reblog') === id); const references = state.get('statuses').filter(item => item.get('reblog') === id);
@@ -183,6 +177,10 @@ function normalizeAccount(state, account, relationship) {
}; };
function normalizeRelationship(state, relationship) { function normalizeRelationship(state, relationship) {
if (state.get('suggestions').includes(relationship.get('id')) && (relationship.get('following') || relationship.get('blocking'))) {
state = state.update('suggestions', list => list.filterNot(id => id === relationship.get('id')));
}
return state.setIn(['relationships', relationship.get('id')], relationship); return state.setIn(['relationships', relationship.get('id')], relationship);
}; };
@@ -210,6 +208,14 @@ function normalizeContext(state, status, ancestors, descendants) {
}); });
}; };
function normalizeSuggestions(state, accounts) {
accounts.forEach(account => {
state = state.setIn(['accounts', account.get('id')], account);
});
return state.set('suggestions', accounts.map(account => account.get('id')));
};
export default function timelines(state = initialState, action) { export default function timelines(state = initialState, action) {
switch(action.type) { switch(action.type) {
case TIMELINE_REFRESH_SUCCESS: case TIMELINE_REFRESH_SUCCESS:
@@ -242,6 +248,8 @@ export default function timelines(state = initialState, action) {
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_TIMELINE_EXPAND_SUCCESS: case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case SUGGESTIONS_FETCH_SUCCESS:
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
default: default:
return state; return state;
} }

View File

@@ -0,0 +1,92 @@
import { createSelector } from 'reselect'
import Immutable from 'immutable';
const getStatuses = state => state.getIn(['timelines', 'statuses']);
const getAccounts = state => state.getIn(['timelines', 'accounts']);
const getAccountBase = (state, id) => state.getIn(['timelines', 'accounts', id], null);
const getAccountRelationship = (state, id) => state.getIn(['timelines', 'relationships', id]);
export const getAccount = createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
if (base === null) {
return null;
}
return base.set('relationship', relationship);
});
const getStatusBase = (state, id) => state.getIn(['timelines', 'statuses', id], null);
export const getStatus = createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
if (base === null) {
return null;
}
return assembleStatus(base.get('id'), statuses, accounts);
});
const getAccountTimelineIds = (state, id) => state.getIn(['timelines', 'accounts_timelines', id], Immutable.List());
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 assembleStatusList = (ids, statuses, accounts) => {
return ids.map(statusId => assembleStatus(statusId, statuses, accounts)).filterNot(status => status === null);
};
export const getAccountTimeline = createSelector([getAccountTimelineIds, getStatuses, getAccounts], assembleStatusList);
const getTimelineIds = (state, timelineType) => state.getIn(['timelines', timelineType]);
export const makeGetTimeline = () => {
return createSelector([getTimelineIds, getStatuses, getAccounts], assembleStatusList);
};
const getStatusAncestorsIds = (state, id) => state.getIn(['timelines', 'ancestors', id], Immutable.OrderedSet());
export const getStatusAncestors = createSelector([getStatusAncestorsIds, getStatuses, getAccounts], assembleStatusList);
const getStatusDescendantsIds = (state, id) => state.getIn(['timelines', 'descendants', id], Immutable.OrderedSet());
export const getStatusDescendants = createSelector([getStatusDescendantsIds, getStatuses, getAccounts], assembleStatusList);
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;
});
const getSuggestionsBase = (state) => state.getIn(['timelines', 'suggestions']);
export const getSuggestions = createSelector([getSuggestionsBase, getAccounts], (base, accounts) => {
return base.map(accountId => accounts.get(accountId));
});

View File

@@ -2,9 +2,10 @@ import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import appReducer from '../reducers'; import appReducer from '../reducers';
import { loadingBarMiddleware } from 'react-redux-loading-bar'; import { loadingBarMiddleware } from 'react-redux-loading-bar';
import errorsMiddleware from '../middleware/errors';
export default function configureStore(initialState) { export default function configureStore(initialState) {
return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({ return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'], promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
})), window.devToolsExtension ? window.devToolsExtension() : f => f)); }), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
}; };

View File

@@ -58,6 +58,13 @@
} }
} }
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
}
.details { .details {
display: flex; display: flex;
margin-top: 30px; margin-top: 30px;

View File

@@ -57,6 +57,43 @@ table {
border-spacing: 0; 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 { body {
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif;
background: #282c37 image-url('background-photo.jpeg'); 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 { .no-list {
list-style: none; list-style: none;
@@ -359,6 +229,7 @@ body {
} }
} }
@import 'forms';
@import 'accounts'; @import 'accounts';
@import 'stream_entries'; @import 'stream_entries';
@import 'components'; @import 'components';

View File

@@ -71,6 +71,7 @@
line-height: 20px; line-height: 20px;
word-wrap: break-word; word-wrap: break-word;
font-weight: 300; font-weight: 300;
overflow: hidden;
p { p {
margin-bottom: 20px; margin-bottom: 20px;
@@ -197,7 +198,7 @@
font-size: 13px; font-size: 13px;
display: block; display: block;
padding: 6px 16px; padding: 6px 16px;
width: 120px; width: 100px;
text-decoration: none; text-decoration: none;
background: #d9e1e8; background: #d9e1e8;
color: #282c37; color: #282c37;
@@ -208,3 +209,54 @@
} }
} }
} }
.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;
}
}

View File

@@ -0,0 +1,182 @@
.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;
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;
}
}
.input.field_with_errors {
input[type=text], input[type=email], input[type=password] {
border-bottom-color: #df405a;
}
.error {
font-weight: 500;
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 {
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;
}
}
}

View File

@@ -46,13 +46,11 @@
} }
} }
&.activity-stream-headless { .entry:first-child {
.entry:first-child { border-radius: 4px 4px 0 0;
border-radius: 4px 4px 0 0;
&:last-child { &:last-child {
border-radius: 4px; border-radius: 4px;
}
} }
} }
@@ -75,19 +73,14 @@
} }
} }
@media screen and (max-width: 360px) {
.avatar {
display: none;
}
}
.entry__container { .entry__container {
display: flex; overflow: hidden;
} }
.avatar { .avatar {
width: 56px; width: 56px;
padding: 15px; padding: 15px;
float: left;
img { img {
width: 56px; width: 56px;
@@ -98,7 +91,7 @@
} }
.entry__container__container { .entry__container__container {
flex-grow: 1; margin-left: 86px;
} }
.header { .header {
@@ -153,10 +146,12 @@
.content { .content {
font-size: 14px; font-size: 14px;
padding: 0 10px; padding: 0 15px;
padding-left: 8px; padding-left: 8px;
padding-bottom: 15px; padding-bottom: 15px;
color: #282c37; color: #282c37;
word-wrap: break-word;
overflow: hidden;
p { p {
margin-bottom: 18px; margin-bottom: 18px;
@@ -224,4 +219,14 @@
} }
} }
} }
@media screen and (max-width: 360px) {
.avatar {
display: none;
}
.entry__container__container {
margin-left: 7px;
}
}
} }

View File

@@ -0,0 +1,19 @@
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
class PublicChannel < ApplicationCable::Channel
def subscribed
stream_from 'timeline:public', -> (encoded_message) do
message = ActiveSupport::JSON.decode(encoded_message)
status = Status.find_by(id: message['id'])
next if status.nil? || current_user.account.blocking?(status.account) || (status.reblog? && current_user.account.blocking?(status.reblog.account))
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
transmit message
end
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

View File

@@ -16,6 +16,16 @@ class AccountsController < ApplicationController
end end
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 def followers
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6) @followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6)
end end

View File

@@ -3,8 +3,14 @@ class Api::SalmonController < ApiController
respond_to :txt respond_to :txt
def update def update
ProcessInteractionService.new.call(request.body.read, @account) body = request.body.read
head 201
if body.nil?
head 200
else
ProcessInteractionService.new.call(body, @account)
head 201
end
end end
private private

View File

@@ -13,8 +13,9 @@ class Api::SubscriptionsController < ApiController
def update def update
body = request.body.read body = request.body.read
subscription = @account.subscription(api_subscription_url(@account.id))
if @account.subscription(api_subscription_url(@account.id)).verify(body, request.headers['HTTP_X_HUB_SIGNATURE']) if subscription.verify(body, request.headers['HTTP_X_HUB_SIGNATURE'])
ProcessFeedService.new.call(body, @account) ProcessFeedService.new.call(body, @account)
head 201 head 201
else else

View File

@@ -1,6 +1,6 @@
class Api::V1::AccountsController < ApiController class Api::V1::AccountsController < ApiController
before_action :doorkeeper_authorize! before_action :doorkeeper_authorize!
before_action :set_account, except: :verify_credentials before_action :set_account, except: [:verify_credentials, :suggestions]
respond_to :json respond_to :json
def show def show
@@ -19,8 +19,13 @@ class Api::V1::AccountsController < ApiController
@followers = @account.followers @followers = @account.followers
end end
def suggestions
@accounts = FollowSuggestion.get(current_user.account_id)
end
def statuses def statuses
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a @statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
end end
def follow def follow
@@ -49,7 +54,7 @@ class Api::V1::AccountsController < ApiController
def relationships def relationships
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i] ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
@accounts = Account.find(ids) @accounts = Account.where(id: ids).select('id')
@following = Account.following_map(ids, current_user.account_id) @following = Account.following_map(ids, current_user.account_id)
@followed_by = Account.followed_by_map(ids, current_user.account_id) @followed_by = Account.followed_by_map(ids, current_user.account_id)
@blocking = Account.blocking_map(ids, current_user.account_id) @blocking = Account.blocking_map(ids, current_user.account_id)

View File

@@ -5,7 +5,7 @@ class Api::V1::FollowsController < ApiController
def create def create
raise ActiveRecord::RecordNotFound if params[:uri].blank? raise ActiveRecord::RecordNotFound if params[:uri].blank?
@account = FollowService.new.call(current_user.account, params[:uri]).try(:target_account) @account = FollowService.new.call(current_user.account, params[:uri].strip).try(:target_account)
render action: :show render action: :show
end end
end end

View File

@@ -4,5 +4,9 @@ class Api::V1::MediaController < ApiController
def create def create
@media = MediaAttachment.create!(account: current_user.account, file: params[:file]) @media = MediaAttachment.create!(account: current_user.account, file: params[:file])
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: { error: 'File type of uploaded media could not be verified' }, status: 422
rescue Paperclip::Error
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
end end
end end

View File

@@ -10,6 +10,7 @@ class Api::V1::StatusesController < ApiController
@status = Status.find(params[:id]) @status = Status.find(params[:id])
@ancestors = @status.ancestors @ancestors = @status.ancestors
@descendants = @status.descendants @descendants = @status.descendants
set_maps([@status] + @ancestors + @descendants)
end end
def create def create
@@ -46,9 +47,19 @@ class Api::V1::StatusesController < ApiController
def home def home
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a @statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
render action: :index
end end
def mentions def mentions
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a @statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
render action: :index
end
def public
@statuses = Status.as_public_timeline(current_user.account).paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
render action: :index
end end
end end

View File

@@ -35,4 +35,10 @@ class ApiController < ApplicationController
def render_empty def render_empty
render json: {}, status: 200 render json: {}, status: 200
end end
def set_maps(statuses)
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact
@reblogs_map = Status.reblogs_map(status_ids, current_user.account)
@favourites_map = Status.favourites_map(status_ids, current_user.account)
end
end end

View File

@@ -10,7 +10,7 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::RoutingError, with: :not_found rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found
before_action :store_current_location, unless: :devise_controller? before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
def raise_not_found def raise_not_found
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}" raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"

View File

@@ -17,6 +17,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def after_sign_up_path_for(_resource) def after_sign_up_path_for(_resource)
root_path new_user_session_path
end end
end end

View File

@@ -0,0 +1,27 @@
class Settings::PreferencesController < ApplicationController
layout 'auth'
before_action :authenticate_user!
def show
end
def update
current_user.settings(:notification_emails).follow = user_params[:notification_emails][:follow] == '1'
current_user.settings(:notification_emails).reblog = user_params[:notification_emails][:reblog] == '1'
current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1'
current_user.settings(:notification_emails).mention = user_params[:notification_emails][:mention] == '1'
if current_user.save
redirect_to settings_preferences_path, notice: 'Changes successfully saved!'
else
render action: :show
end
end
private
def user_params
params.require(:user).permit(notification_emails: [:follow, :reblog, :favourite, :mention])
end
end

View File

@@ -1,4 +1,4 @@
class SettingsController < ApplicationController class Settings::ProfilesController < ApplicationController
layout 'auth' layout 'auth'
before_action :authenticate_user! before_action :authenticate_user!
@@ -9,7 +9,7 @@ class SettingsController < ApplicationController
def update def update
if @account.update(account_params) if @account.update(account_params)
redirect_to settings_path redirect_to settings_profile_path, notice: 'Changes successfully saved!'
else else
render action: :show render action: :show
end end

View File

@@ -1,27 +1,40 @@
class XrdController < ApplicationController class XrdController < ApplicationController
before_action :set_format before_action :set_default_format_json, only: :webfinger
before_action :set_default_format_xml, only: :host_meta
def host_meta def host_meta
@webfinger_template = "#{webfinger_url}?resource={uri}" @webfinger_template = "#{webfinger_url}?resource={uri}"
respond_to do |format|
format.xml { render content_type: 'application/xrd+xml' }
end
end end
def webfinger def webfinger
@account = Account.find_local!(username_from_resource) @account = Account.find_local!(username_from_resource)
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}" @canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
@magic_key = pem_to_magic_key(@account.keypair.public_key) @magic_key = pem_to_magic_key(@account.keypair.public_key)
respond_to do |format|
format.xml { render content_type: 'application/xrd+xml' }
format.json { render content_type: 'application/jrd+json' }
end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
head 404 head 404
end end
private private
def set_format def set_default_format_xml
request.format = 'xml' request.format = 'xml' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
response.headers['Content-Type'] = 'application/xrd+xml' end
def set_default_format_json
request.format = 'json' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
end end
def username_from_resource def username_from_resource
if resource_param.start_with?('acct:') if resource_param.start_with?('acct:') || resource_param.include?('@')
resource_param.split('@').first.gsub('acct:', '') resource_param.split('@').first.gsub('acct:', '')
else else
url = Addressable::URI.parse(resource_param) url = Addressable::URI.parse(resource_param)

View File

@@ -149,6 +149,7 @@ module AtomBuilderHelper
verb xml, stream_entry.verb verb xml, stream_entry.verb
link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom') link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')
link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry) link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry)
object_type xml, stream_entry.object_type
# Comments need thread element # Comments need thread element
if stream_entry.threaded? if stream_entry.threaded?
@@ -157,17 +158,18 @@ module AtomBuilderHelper
if stream_entry.targeted? if stream_entry.targeted?
target(xml) do target(xml) do
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
if stream_entry.target.object_type == :person if stream_entry.target.object_type == :person
include_author xml, stream_entry.target include_author xml, stream_entry.target
else else
object_type xml, stream_entry.target.object_type object_type xml, stream_entry.target.object_type
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
title xml, stream_entry.target.title title xml, stream_entry.target.title
link_alternate xml, TagManager.instance.url_for(stream_entry.target) link_alternate xml, TagManager.instance.url_for(stream_entry.target)
end end
# Statuses have content and author # Statuses have content and author
if [:note, :comment].include? stream_entry.target.object_type if stream_entry.target.is_a?(Status)
content xml, conditionally_formatted(stream_entry.target) content xml, conditionally_formatted(stream_entry.target)
verb xml, stream_entry.target.verb verb xml, stream_entry.target.verb
published_at xml, stream_entry.target.created_at published_at xml, stream_entry.target.created_at
@@ -176,10 +178,16 @@ module AtomBuilderHelper
author(xml) do author(xml) do
include_author xml, stream_entry.target.account include_author xml, stream_entry.target.account
end end
stream_entry.target.mentions.each do |mention|
link_mention xml, mention.account
end
stream_entry.target.media_attachments.each do |media|
link_enclosure xml, media
end
end end
end end
else
object_type xml, stream_entry.object_type
end end
stream_entry.mentions.each do |mentioned| stream_entry.mentions.each do |mentioned|

View File

@@ -2,13 +2,7 @@ module HomeHelper
def default_props def default_props
{ {
token: @token, token: @token,
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json)
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json),
timelines: {
home: render(file: 'api/v1/statuses/home', locals: { statuses: @home }, formats: :json),
mentions: render(file: 'api/v1/statuses/mentions', locals: { statuses: @mentions }, formats: :json)
}
} }
end end
end end

View File

@@ -1,2 +0,0 @@
module SettingsHelper
end

View File

@@ -33,22 +33,6 @@ class FeedManager
redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}") redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}")
end end
private
def redis
$redis
end
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
def filter_from_home?(status, receiver)
replied_to_user = status.reply? ? status.thread.account : nil
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user)))
end
def filter_from_mentions?(status, receiver)
receiver.blocking?(status.account) || (status.reblog? && receiver.blocking?(status.reblog.account))
end
def inline_render(target_account, status) def inline_render(target_account, status)
rabl_scope = Class.new do rabl_scope = Class.new do
include RoutingHelper include RoutingHelper
@@ -58,7 +42,7 @@ class FeedManager
end end
def current_user def current_user
@account.user @account.try(:user)
end end
def current_account def current_account
@@ -68,4 +52,20 @@ class FeedManager
Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
end end
private
def redis
$redis
end
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
def filter_from_home?(status, receiver)
replied_to_user = status.reply? ? status.thread.account : nil
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user))) || (status.reblog? && receiver.blocking?(status.reblog.account))
end
def filter_from_mentions?(status, receiver)
receiver.blocking?(status.account)
end
end end

View File

@@ -29,7 +29,9 @@ class Formatter
end end
def link_urls(html) def link_urls(html)
auto_link(html, link: :urls, html: { rel: 'nofollow noopener' }) auto_link(html, link: :urls, html: { rel: 'nofollow noopener' }) do |text|
truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30)
end
end end
def link_mentions(html, mentions) def link_mentions(html, mentions)

View File

@@ -17,6 +17,10 @@ class TagManager
id.start_with?("tag:#{Rails.configuration.x.local_domain}") id.start_with?("tag:#{Rails.configuration.x.local_domain}")
end end
def local_domain?(domain)
domain.nil? || domain.gsub(/[\/]/, '').downcase == Rails.configuration.x.local_domain.downcase
end
def uri_for(target) def uri_for(target)
return target.uri if target.respond_to?(:local?) && !target.local? return target.uri if target.respond_to?(:local?) && !target.local?

View File

@@ -1,11 +1,11 @@
class NotificationMailer < ApplicationMailer class NotificationMailer < ApplicationMailer
helper StreamEntriesHelper helper StreamEntriesHelper
helper AtomBuilderHelper
def mention(mentioned_account, status) def mention(mentioned_account, status)
@me = mentioned_account @me = mentioned_account
@status = status @status = status
return unless @me.user.settings(:notification_emails).mention
mail to: @me.user.email, subject: "You were mentioned by #{@status.account.acct}" mail to: @me.user.email, subject: "You were mentioned by #{@status.account.acct}"
end end
@@ -13,6 +13,7 @@ class NotificationMailer < ApplicationMailer
@me = followed_account @me = followed_account
@account = follower @account = follower
return unless @me.user.settings(:notification_emails).follow
mail to: @me.user.email, subject: "#{@account.acct} is now following you" mail to: @me.user.email, subject: "#{@account.acct} is now following you"
end end
@@ -21,6 +22,7 @@ class NotificationMailer < ApplicationMailer
@account = from_account @account = from_account
@status = target_status @status = target_status
return unless @me.user.settings(:notification_emails).favourite
mail to: @me.user.email, subject: "#{@account.acct} favourited your status" mail to: @me.user.email, subject: "#{@account.acct} favourited your status"
end end
@@ -29,6 +31,7 @@ class NotificationMailer < ApplicationMailer
@account = from_account @account = from_account
@status = target_status @status = target_status
return unless @me.user.settings(:notification_emails).reblog
mail to: @me.user.email, subject: "#{@account.acct} reblogged your status" mail to: @me.user.email, subject: "#{@account.acct} reblogged your status"
end end
end end

View File

@@ -24,10 +24,10 @@ class Account < ApplicationRecord
validates :note, length: { maximum: 124 }, if: 'local?' validates :note, length: { maximum: 124 }, if: 'local?'
# Timelines # Timelines
has_many :stream_entries, inverse_of: :account has_many :stream_entries, inverse_of: :account, dependent: :destroy
has_many :statuses, inverse_of: :account has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account has_many :mentions, inverse_of: :account, dependent: :destroy
# Follow relations # Follow relations
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
@@ -48,6 +48,8 @@ class Account < ApplicationRecord
scope :with_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) > 0') } scope :with_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) > 0') }
scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers } scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
scope :with_counters, -> { select('accounts.*, (select count(f.id) from follows as f where f.target_account_id = accounts.id) as followers_count, (select count(f.id) from follows as f where f.account_id = accounts.id) as following_count, (select count(s.id) from statuses as s where s.account_id = accounts.id) as statuses_count') }
def follow!(other_account) def follow!(other_account)
active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
end end
@@ -125,7 +127,7 @@ class Account < ApplicationRecord
end end
def self.find_remote!(username, domain) def self.find_remote!(username, domain)
where(arel_table[:username].matches(username)).where(domain: domain).take! where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
end end
def self.find_local(username) def self.find_local(username)

View File

@@ -4,8 +4,8 @@ module Paginable
included do included do
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil) def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
query = order('id desc').limit(limit) query = order('id desc').limit(limit)
query = query.where('id < ?', max_id) unless max_id.blank? query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
query = query.where('id > ?', since_id) unless since_id.blank? query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
query query
end end
end end

View File

@@ -0,0 +1,7 @@
class DomainBlock < ApplicationRecord
validates :domain, presence: true, uniqueness: true
def self.blocked?(domain)
where(domain: domain).exists?
end
end

View File

@@ -22,4 +22,32 @@ class Follow < ApplicationRecord
def title def title
destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}" destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
end end
after_create :add_to_graph
after_destroy :remove_from_graph
def sync!
add_to_graph
end
private
def add_to_graph
neo = Neography::Rest.new
a = neo.create_unique_node('account_index', 'Account', account_id.to_s, account_id: account_id)
b = neo.create_unique_node('account_index', 'Account', target_account_id.to_s, account_id: target_account_id)
neo.create_unique_relationship('follow_index', 'Follow', id.to_s, 'follows', a, b)
rescue Neography::NeographyError, Excon::Error::Socket => e
Rails.logger.error e
end
def remove_from_graph
neo = Neography::Rest.new
rel = neo.get_relationship_index('follow_index', 'Follow', id.to_s)
neo.delete_relationship(rel)
rescue Neography::NeographyError, Excon::Error::Socket => e
Rails.logger.error e
end
end end

View File

@@ -0,0 +1,50 @@
class FollowSuggestion
def self.get(for_account_id, limit = 10)
neo = Neography::Rest.new
query = <<END
START a=node:account_index(Account={id})
MATCH (a)-[:follows]->(b)-[:follows]->(c)
WHERE a <> c
AND NOT (a)-[:follows]->(c)
RETURN DISTINCT c.account_id, count(b), c.nodeRank
ORDER BY count(b) DESC, c.nodeRank DESC
LIMIT {limit}
END
results = neo.execute_query(query, id: for_account_id, limit: limit)
if results.empty? || results['data'].empty?
results = fallback(for_account_id, limit)
elsif results['data'].size < limit
results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq
end
account_ids = results['data'].map(&:first)
blocked_ids = Block.where(account_id: for_account_id).pluck(:target_account_id)
accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h
account_ids.map { |id| accounts_map[id] }.compact
rescue Neography::NeographyError, Excon::Error::Socket => e
Rails.logger.error e
return []
end
private
def self.fallback(for_account_id, limit)
neo = Neography::Rest.new
query = <<END
START a=node:account_index(Account={id})
MATCH (b)
WHERE a <> b
AND NOT (a)-[:follows]->(b)
RETURN b.account_id
ORDER BY b.nodeRank DESC
LIMIT {limit}
END
neo.execute_query(query, id: for_account_id, limit: limit)
end
end

View File

@@ -1,11 +1,14 @@
class MediaAttachment < ApplicationRecord class MediaAttachment < ApplicationRecord
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
VIDEO_MIME_TYPES = ['video/webm'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
belongs_to :account, inverse_of: :media_attachments belongs_to :account, inverse_of: :media_attachments
belongs_to :status, inverse_of: :media_attachments belongs_to :status, inverse_of: :media_attachments
has_attached_file :file, styles: -> (f) { f.instance.image? ? { small: '510x680>' } : { small: { convert_options: { output: { vf: 'scale="min(510\, iw):min(680\, ih)":force_original_aspect_ratio=decrease' } }, format: 'png', time: 1 } } }, processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] } has_attached_file :file,
styles: -> (f) { file_styles f },
processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
convert_options: { all: "-strip" }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
validates_attachment_size :file, less_than: 4.megabytes validates_attachment_size :file, less_than: 4.megabytes
@@ -30,4 +33,31 @@ class MediaAttachment < ApplicationRecord
def type def type
image? ? 'image' : 'video' image? ? 'image' : 'video'
end end
private
def self.file_styles(f)
if f.instance.image?
{
original: '100%',
small: '510x680>'
}
else
{
original: {
convert_options: {},
format: 'webm'
},
small: {
convert_options: {
output: {
vf: 'scale=\'min(510\, iw):min(680\, ih)\':force_original_aspect_ratio=decrease'
}
},
format: 'png',
time: 1
}
}
end
end
end end

View File

@@ -2,7 +2,7 @@ class Status < ApplicationRecord
include Paginable include Paginable
include Streamable include Streamable
belongs_to :account, inverse_of: :statuses belongs_to :account, -> { with_counters }, inverse_of: :statuses
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
@@ -18,6 +18,8 @@ class Status < ApplicationRecord
validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? } validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?' validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
default_scope { order('id desc') }
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') } scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) } scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
@@ -83,12 +85,16 @@ class Status < ApplicationRecord
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
end end
def self.as_public_timeline(account)
joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id').where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id).with_includes.with_counters
end
def self.favourites_map(status_ids, account_id) def self.favourites_map(status_ids, account_id)
Favourite.where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
end end
def self.reblogs_map(status_ids, account_id) def self.reblogs_map(status_ids, account_id)
where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
end end
before_validation do before_validation do

View File

@@ -39,7 +39,7 @@ class StreamEntry < ApplicationRecord
end end
def threaded? def threaded?
verb == :favorite || object_type == :comment (verb == :favorite || object_type == :comment) && !thread.nil?
end end
def thread def thread

View File

@@ -9,4 +9,8 @@ class User < ApplicationRecord
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') } scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
scope :recent, -> { order('created_at desc') } scope :recent, -> { order('created_at desc') }
scope :admins, -> { where(admin: true) } scope :admins, -> { where(admin: true) }
has_settings do |s|
s.key :notification_emails, defaults: { follow: true, reblog: true, favourite: true, mention: true }
end
end end

View File

@@ -0,0 +1,13 @@
class BlockDomainService < BaseService
def call(domain)
block = DomainBlock.find_or_create_by!(domain: domain)
Account.where(domain: domain).find_each do |account|
if account.subscribed?
account.subscription(api_subscription_url(account.id)).unsubscribe
end
account.destroy!
end
end
end

View File

@@ -5,6 +5,7 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local? deliver_to_self(status) if status.account.local?
deliver_to_followers(status) deliver_to_followers(status)
deliver_to_mentioned(status) deliver_to_mentioned(status)
deliver_to_public(status)
end end
private private
@@ -27,4 +28,8 @@ class FanOutOnWriteService < BaseService
FeedManager.instance.push(:mentions, mentioned_account, status) FeedManager.instance.push(:mentions, mentioned_account, status)
end end
end end
def deliver_to_public(status)
FeedManager.instance.broadcast(:public, id: status.id)
end
end end

View File

@@ -19,6 +19,8 @@ class FetchRemoteAccountService < BaseService
Rails.logger.debug "Going to webfinger #{username}@#{domain}" Rails.logger.debug "Going to webfinger #{username}@#{domain}"
return FollowRemoteAccountService.new.call("#{username}@#{domain}") return FollowRemoteAccountService.new.call("#{username}@#{domain}")
rescue TypeError => e
Rails.logger.debug "Unparseable URL given: #{url}"
rescue Nokogiri::XML::XPath::SyntaxError rescue Nokogiri::XML::XPath::SyntaxError
Rails.logger.debug "Invalid XML or missing namespace" Rails.logger.debug "Invalid XML or missing namespace"
end end

View File

@@ -1,4 +1,8 @@
class FollowRemoteAccountService < BaseService class FollowRemoteAccountService < BaseService
include OStatus2::MagicKey
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'.freeze
# Find or create a local account for a remote user. # Find or create a local account for a remote user.
# When creating, look up the user's webfinger and fetch all # When creating, look up the user's webfinger and fetch all
# important information from their feed # important information from their feed
@@ -7,7 +11,8 @@ class FollowRemoteAccountService < BaseService
def call(uri) def call(uri)
username, domain = uri.split('@') username, domain = uri.split('@')
return Account.find_local(username) if domain == Rails.configuration.x.local_domain || domain.nil? return Account.find_local(username) if TagManager.instance.local_domain?(domain)
return nil if DomainBlock.blocked?(domain)
account = Account.find_remote(username, domain) account = Account.find_remote(username, domain)
@@ -18,27 +23,21 @@ class FollowRemoteAccountService < BaseService
data = Goldfinger.finger("acct:#{uri}") data = Goldfinger.finger("acct:#{uri}")
raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil?
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
account.salmon_url = data.link('salmon').href account.salmon_url = data.link('salmon').href
account.url = data.link('http://webfinger.net/rel/profile-page').href account.url = data.link('http://webfinger.net/rel/profile-page').href
account.public_key = magic_key_to_pem(data.link('magic-public-key').href) account.public_key = magic_key_to_pem(data.link('magic-public-key').href)
account.private_key = nil account.private_key = nil
feed = get_feed(account.remote_url) xml = get_feed(account.remote_url)
hubs = feed.xpath('//xmlns:link[@rel="hub"]') hubs = get_hubs(xml)
if hubs.empty? || hubs.first.attribute('href').nil? account.uri = get_account_uri(xml)
raise Goldfinger::Error, 'No PubSubHubbub hubs found'
end
if feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').nil?
raise Goldfinger::Error, 'No author URI found'
end
account.uri = feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').content
account.hub_url = hubs.first.attribute('href').value account.hub_url = hubs.first.attribute('href').value
get_profile(feed, account) get_profile(xml, account)
account.save! account.save!
return account return account
@@ -51,20 +50,27 @@ class FollowRemoteAccountService < BaseService
Nokogiri::XML(response) Nokogiri::XML(response)
end end
def get_profile(xml, account) def get_hubs(xml)
author = xml.at_xpath('/xmlns:feed/xmlns:author') hubs = xml.xpath('//xmlns:link[@rel="hub"]')
update_remote_profile_service.call(author, account) raise Goldfinger::Error, 'No PubSubHubbub hubs found' if hubs.empty? || hubs.first.attribute('href').nil?
hubs
end end
def magic_key_to_pem(magic_key) def get_account_uri(xml)
_, modulus, exponent = magic_key.split('.') author_uri = xml.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri')
modulus, exponent = [modulus, exponent].map { |n| Base64.urlsafe_decode64(n).bytes.inject(0) { |a, e| (a << 8) | e } }
key = OpenSSL::PKey::RSA.new if author_uri.nil?
key.n = modulus owner = xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
key.e = exponent author_uri = owner.at_xpath('./xmlns:uri') unless owner.nil?
end
key.to_pem raise Goldfinger::Error, 'Author URI could not be found' if author_uri.nil?
author_uri.content
end
def get_profile(xml, account)
author = xml.at_xpath('/xmlns:feed/xmlns:author') || xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
update_remote_profile_service.call(author, account)
end end
def update_remote_profile_service def update_remote_profile_service

View File

@@ -5,7 +5,7 @@ class FollowService < BaseService
def call(source_account, uri) def call(source_account, uri)
target_account = follow_remote_account_service.call(uri) target_account = follow_remote_account_service.call(uri)
return nil if target_account.nil? || target_account.id == source_account.id raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id
follow = source_account.follow!(target_account) follow = source_account.follow!(target_account)

View File

@@ -1,4 +1,7 @@
class ProcessFeedService < BaseService class ProcessFeedService < BaseService
ACTIVITY_NS = 'http://activitystrea.ms/spec/1.0/'.freeze
THREAD_NS = 'http://purl.org/syndication/thread/1.0'.freeze
# Create local statuses from an Atom feed # Create local statuses from an Atom feed
# @param [String] body Atom feed # @param [String] body Atom feed
# @param [Account] account Account this feed belongs to # @param [Account] account Account this feed belongs to
@@ -34,23 +37,27 @@ class ProcessFeedService < BaseService
else else
add_reply!(entry, status) add_reply!(entry, status)
end end
else
return
end end
# If we added a status, go through accounts it mentions and create respective relations # If we added a status, go through accounts it mentions and create respective relations
# Also record all media attachments for the status and for the reblogged status if present # Also record all media attachments for the status and for the reblogged status if present
unless status.new_record? unless status.new_record?
record_remote_mentions(status, entry.xpath('./xmlns:link[@rel="mentioned"]')) record_remote_mentions(status, entry.xpath('./xmlns:link[@rel="mentioned"]'))
record_remote_mentions(status.reblog, entry.at_xpath('./activity:object', activity: ACTIVITY_NS).xpath('./xmlns:link[@rel="mentioned"]')) if status.reblog?
process_attachments(entry, status) process_attachments(entry, status)
process_attachments(entry.xpath('./activity:object'), status.reblog) if status.reblog? process_attachments(entry.xpath('./activity:object', activity: ACTIVITY_NS), status.reblog) if status.reblog?
DistributionWorker.perform_async(status.id) DistributionWorker.perform_async(status.id)
return status
end end
return status
end end
def record_remote_mentions(status, links) def record_remote_mentions(status, links)
return if status.local?
# Here we have to do a reverse lookup of local accounts by their URL! # Here we have to do a reverse lookup of local accounts by their URL!
# It's not pretty at all! I really wish all these protocols sticked to # It's not pretty at all! I really wish all these protocols sticked to
# using acct:username@domain only! It would make things so much easier # using acct:username@domain only! It would make things so much easier
@@ -63,7 +70,7 @@ class ProcessFeedService < BaseService
href = Addressable::URI.parse(href_val) href = Addressable::URI.parse(href_val)
if href.host == Rails.configuration.x.local_domain if TagManager.instance.local_domain?(href.host)
# A local user is mentioned # A local user is mentioned
mentioned_account = Account.find_local(href.path.gsub('/users/', '')) mentioned_account = Account.find_local(href.path.gsub('/users/', ''))
@@ -88,6 +95,8 @@ class ProcessFeedService < BaseService
end end
def process_attachments(entry, status) def process_attachments(entry, status)
return if status.local?
entry.xpath('./xmlns:link[@rel="enclosure"]').each do |enclosure_link| entry.xpath('./xmlns:link[@rel="enclosure"]').each do |enclosure_link|
next if enclosure_link.attribute('href').nil? next if enclosure_link.attribute('href').nil?
@@ -95,9 +104,14 @@ class ProcessFeedService < BaseService
next unless media.nil? next unless media.nil?
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value) begin
media.file_remote_url = enclosure_link.attribute('href').value media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
media.save media.file_remote_url = enclosure_link.attribute('href').value
media.save
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
Rails.logger.debug "Error saving attachment from #{enclosure_link.attribute('href').value}"
next
end
end end
end end
@@ -142,10 +156,10 @@ class ProcessFeedService < BaseService
end end
def fetch_remote_status(xml) def fetch_remote_status(xml)
username = xml.at_xpath('./activity:object/xmlns:author/xmlns:name').content username = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:name').content
url = xml.at_xpath('./activity:object/xmlns:author/xmlns:uri').content url = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:uri').content
domain = Addressable::URI.parse(url).host domain = Addressable::URI.parse(url).host
account = Account.find_by(username: username, domain: domain) account = Account.find_remote(username, domain)
if account.nil? if account.nil?
account = follow_remote_account_service.call("#{username}@#{domain}") account = follow_remote_account_service.call("#{username}@#{domain}")
@@ -172,23 +186,23 @@ class ProcessFeedService < BaseService
end end
def content(xml) def content(xml)
xml.at_xpath('./xmlns:content').content xml.at_xpath('./xmlns:content').try(:content)
end end
def thread_id(xml) def thread_id(xml)
xml.at_xpath('./thr:in-reply-to').attribute('ref').value xml.at_xpath('./thr:in-reply-to', thr: THREAD_NS).attribute('ref').value
rescue rescue
nil nil
end end
def thread_href(xml) def thread_href(xml)
xml.at_xpath('./thr:in-reply-to').attribute('href').value xml.at_xpath('./thr:in-reply-to', thr: THREAD_NS).attribute('href').value
rescue rescue
nil nil
end end
def target_id(xml) def target_id(xml)
xml.at_xpath('.//activity:object/xmlns:id').content xml.at_xpath('.//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:id').content
rescue rescue
nil nil
end end
@@ -204,21 +218,21 @@ class ProcessFeedService < BaseService
end end
def target_content(xml) def target_content(xml)
xml.at_xpath('.//activity:object/xmlns:content').content xml.at_xpath('.//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:content').content
end end
def target_url(xml) def target_url(xml)
xml.at_xpath('.//activity:object/xmlns:link[@rel="alternate"]').attribute('href').value xml.at_xpath('.//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:link[@rel="alternate"]').attribute('href').value
end end
def object_type(xml) def object_type(xml)
xml.at_xpath('./activity:object-type').content.gsub('http://activitystrea.ms/schema/1.0/', '').to_sym xml.at_xpath('./activity:object-type', activity: ACTIVITY_NS).content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
rescue rescue
:note :activity
end end
def verb(xml) def verb(xml)
xml.at_xpath('./activity:verb').content.gsub('http://activitystrea.ms/schema/1.0/', '').to_sym xml.at_xpath('./activity:verb', activity: ACTIVITY_NS).content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
rescue rescue
:post :post
end end

View File

@@ -1,4 +1,6 @@
class ProcessInteractionService < BaseService class ProcessInteractionService < BaseService
ACTIVITY_NS = 'http://activitystrea.ms/spec/1.0/'.freeze
# Record locally the remote interaction with our user # Record locally the remote interaction with our user
# @param [String] envelope Salmon envelope # @param [String] envelope Salmon envelope
# @param [Account] target_account Account the Salmon was addressed to # @param [Account] target_account Account the Salmon was addressed to
@@ -13,6 +15,8 @@ class ProcessInteractionService < BaseService
domain = Addressable::URI.parse(url).host domain = Addressable::URI.parse(url).host
account = Account.find_by(username: username, domain: domain) account = Account.find_by(username: username, domain: domain)
return if DomainBlock.blocked?(domain)
if account.nil? if account.nil?
account = follow_remote_account_service.call("#{username}@#{domain}") account = follow_remote_account_service.call("#{username}@#{domain}")
end end
@@ -35,7 +39,7 @@ class ProcessInteractionService < BaseService
delete_post!(xml, account) delete_post!(xml, account)
end end
end end
rescue Goldfinger::Error, HTTP::Error rescue Goldfinger::Error, HTTP::Error, OStatus2::BadSalmonError
nil nil
end end
@@ -51,7 +55,7 @@ class ProcessInteractionService < BaseService
end end
def verb(xml) def verb(xml)
xml.at_xpath('//activity:verb').content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym xml.at_xpath('//activity:verb', activity: ACTIVITY_NS).content.gsub('http://activitystrea.ms/schema/1.0/', '').gsub('http://ostatus.org/schema/1.0/', '').to_sym
rescue rescue
:post :post
end end
@@ -90,7 +94,7 @@ class ProcessInteractionService < BaseService
end end
def activity_id(xml) def activity_id(xml)
xml.at_xpath('//activity:object/xmlns:id').content xml.at_xpath('//activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:id').content
end end
def salmon def salmon

View File

@@ -34,7 +34,8 @@ class RemoveStatusService < BaseService
end end
def send_delete_salmon(account, status) def send_delete_salmon(account, status)
NotificationWorker.perform_async(status.stream_entry_id, account.id) return unless status.local?
NotificationWorker.perform_async(status.stream_entry.id, account.id)
end end
def remove_reblogs(status) def remove_reblogs(status)

View File

@@ -1,13 +1,17 @@
class UpdateRemoteProfileService < BaseService class UpdateRemoteProfileService < BaseService
POCO_NS = 'http://portablecontacts.net/spec/1.0'
def call(author_xml, account) def call(author_xml, account)
if author_xml.at_xpath('./poco:displayName').nil? return if author_xml.nil?
if author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil?
account.display_name = account.username account.display_name = account.username
else else
account.display_name = author_xml.at_xpath('./poco:displayName').content account.display_name = author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content
end end
unless author_xml.at_xpath('./poco:note').nil? unless author_xml.at_xpath('./poco:note').nil?
account.note = author_xml.at_xpath('./poco:note').content account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content
end end
unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil?

View File

@@ -1,3 +1,6 @@
- content_for :page_title do
= Rails.configuration.x.local_domain
.wrapper .wrapper
%h1 %h1
= image_tag 'logo.png' = image_tag 'logo.png'
@@ -16,4 +19,5 @@
is a Mastodon instance. is a Mastodon instance.
.actions .actions
= link_to 'Get started', new_user_session_path, class: 'button' = link_to 'Get started', new_user_registration_path, class: 'button'
= link_to 'Log in', new_user_session_path, class: 'button'

View File

@@ -1,4 +1,11 @@
.card{ style: "background-image: url(#{@account.header.url(:medium)})" } .card{ style: "background-image: url(#{@account.header.url(:medium)})" }
- if user_signed_in? && current_account.id != @account.id
.controls
- if current_account.following?(@account)
= link_to 'Unfollow', unfollow_account_path(@account), data: { method: :post }, class: 'button'
- else
= link_to 'Follow', follow_account_path(@account), data: { method: :post }, class: 'button'
.avatar= image_tag @account.avatar.url(:large) .avatar= image_tag @account.avatar.url(:large)
%h1.name %h1.name
= display_name(@account) = display_name(@account)

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