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/assets
node_modules
storybook
neo4j

View File

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

1
.gitignore vendored
View File

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

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
6.7.0

View File

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

View File

@@ -3,7 +3,9 @@ FROM ruby:2.2.4
ENV RAILS_ENV=production
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs nodejs-legacy npm ffmpeg && rm -rf /var/lib/apt/lists/*
RUN curl -sL https://deb.nodesource.com/setup_4.x | bash -
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/*
RUN npm install -g npm@3 && npm install -g yarn
RUN mkdir /mastodon
WORKDIR /mastodon
@@ -13,7 +15,8 @@ ADD Gemfile.lock /mastodon/Gemfile.lock
RUN bundle install --deployment --without test development
ADD package.json /mastodon/package.json
RUN npm install
ADD yarn.lock /mastodon/yarn.lock
RUN yarn
ADD . /mastodon

17
Dockerfile.neo4j Normal file
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 'rack-attack'
gem 'sidekiq'
gem 'ledermann-rails-settings'
gem 'neography'
gem 'react-rails'
gem 'browserify-rails'

View File

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

View File

@@ -57,6 +57,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
- PostgreSQL
- Redis
- Neo4J (optional)
- GraphAware NodeRank
## Running with Docker and Docker-Compose
@@ -86,6 +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:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
Running any of these tasks via docker-compose would look like this:

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.ReactDOM = require('react-dom');
window.Perf = require('react-addons-perf');
//= require_tree ./components

View File

@@ -1,5 +1,6 @@
import api from '../api'
import axios from 'axios';
import api from '../api'
import axios from 'axios';
import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
@@ -66,7 +67,7 @@ export function fetchAccountTimeline(id) {
export function expandAccountTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_timelines', id]).last();
const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
dispatch(expandAccountTimelineRequest(id));

View File

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

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 () {
this._updateMomentText();
this.interval = setInterval(this._updateMomentText, 6000);
this.interval = setInterval(this._updateMomentText, 60000);
},
componentWillUnmount () {

View File

@@ -41,16 +41,21 @@ const Status = React.createClass({
},
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 (
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}>
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div>
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{status.getIn(['account', 'display_name'])}</strong></a> reblogged
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{displayName}</strong></a> reblogged
</div>
<Status {...other} wrapped={true} status={status.get('reblog')} />

View File

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

View File

@@ -22,11 +22,11 @@ const StatusContent = React.createClass({
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention));
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
} else {
link.setAttribute('target', '_blank');
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;
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>
{statuses.map((status) => {
return <Status key={status.get('id')} {...other} status={status} />;

View File

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

View File

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

View File

@@ -4,24 +4,41 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const Header = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
render () {
const { account } = this.props;
const { account, me } = this.props;
let displayName = account.get('display_name');
let info = '';
if (displayName.length === 0) {
displayName = account.get('username');
}
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
}
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={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div>
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div>
<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>
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
{info}
</div>
</div>
);

View File

@@ -10,30 +10,17 @@ 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 Header from './components/header';
import {
selectStatus,
selectAccount
} from '../../reducers/timelines';
import StatusList from '../../components/status_list';
import Immutable from 'immutable';
getAccountTimeline,
getAccount
} from '../../selectors';
import LoadingIndicator from '../../components/loading_indicator';
import ActionBar from './components/action_bar';
function selectStatuses(state, accountId) {
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null);
};
import Column from '../ui/components/column';
const mapStateToProps = (state, props) => ({
account: selectAccount(state, Number(props.params.accountId)),
statuses: selectStatuses(state, Number(props.params.accountId)),
account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
});
@@ -43,20 +30,18 @@ const Account = React.createClass({
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
statuses: ImmutablePropTypes.list
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
}
},
@@ -76,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 () {
const { account, statuses, me } = this.props;
const { account, me } = this.props;
if (account === null) {
return <div>Loading {this.props.params.accountId}...</div>;
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
<Header account={account} />
<Column>
<Header account={account} me={me} />
<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 IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenu from '../../../components/dropdown_menu';
const ActionBar = React.createClass({
propTypes: {
status: ImmutablePropTypes.map.isRequired,
onReply: React.PropTypes.func.isRequired,
onReblog: React.PropTypes.func.isRequired,
onFavourite: React.PropTypes.func.isRequired
onFavourite: React.PropTypes.func.isRequired,
onDelete: React.PropTypes.func.isRequired,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
render () {
const { status } = this.props;
const { status, me } = this.props;
let menu = [];
if (me === status.getIn(['account', 'id'])) {
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
}
return (
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
</div>
);
}

View File

@@ -4,20 +4,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses';
import Immutable from 'immutable';
import EmbeddedStatus from '../../components/status';
import LoadingIndicator from '../../components/loading_indicator';
import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose';
import { selectStatus } from '../../reducers/timelines';
function selectStatuses(state, ids) {
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null);
};
import { deleteStatus } from '../../actions/statuses';
import {
getStatus,
getStatusAncestors,
getStatusDescendants
} from '../../selectors';
const mapStateToProps = (state, props) => ({
status: selectStatus(state, Number(props.params.statusId)),
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.OrderedSet())),
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.OrderedSet()))
status: getStatus(state, Number(props.params.statusId)),
ancestors: getStatusAncestors(state, Number(props.params.statusId)),
descendants: getStatusDescendants(state, Number(props.params.statusId)),
me: state.getIn(['timelines', 'me'])
});
const Status = React.createClass({
@@ -54,28 +58,38 @@ const Status = React.createClass({
this.props.dispatch(reblog(status));
},
handleDeleteClick (status) {
this.props.dispatch(deleteStatus(status.get('id')));
},
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 () {
const { status, ancestors, descendants } = this.props;
const { status, ancestors, descendants, me } = this.props;
if (status === null) {
return <div>Loading {this.props.params.statusId}...</div>;
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const account = status.get('account');
return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
<div>{this.renderChildren(ancestors)}</div>
<Column>
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
<div>{this.renderChildren(ancestors)}</div>
<DetailedStatus status={status} />
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
<DetailedStatus status={status} me={me} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />
<div>{this.renderChildren(descendants)}</div>
</div>
<div>{this.renderChildren(descendants)}</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;
}
node.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration));
node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
requestAnimationFrame(step);
};
@@ -29,6 +29,12 @@ const scrollTop = (node) => {
};
};
const style = {
boxSizing: 'border-box',
background: '#282c37',
display: 'flex',
flexDirection: 'column'
};
const Column = React.createClass({
@@ -50,10 +56,6 @@ const Column = React.createClass({
}
},
handleScroll () {
// todo
},
render () {
let header = '';
@@ -61,10 +63,8 @@ const Column = React.createClass({
header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />;
}
const style = { width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', marginBottom: '0', display: 'flex', flexDirection: 'column' };
return (
<div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}>
<div className='column' style={style} onWheel={this.handleWheel}>
{header}
{this.props.children}
</div>

View File

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

View File

@@ -1,12 +1,21 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
const style = {
boxSizing: 'border-box',
background: '#454b5e',
padding: '0',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto'
};
const Drawer = React.createClass({
mixins: [PureRenderMixin],
render () {
return (
<div style={{ width: '280px', flex: '0 0 auto', boxSizing: 'border-box', background: '#454b5e', margin: '10px', marginRight: '0', padding: '0', display: 'flex', flexDirection: 'column' }}>
<div className='drawer' style={style}>
{this.props.children}
</div>
);

View File

@@ -33,7 +33,7 @@ const FollowForm = React.createClass({
render () {
return (
<div style={{ display: 'flex', lineHeight: '20px', padding: '10px', background: '#373b4a' }}>
<input type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
<input autoComplete='off' type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
<div style={{ padding: '10px', paddingRight: '0' }}><IconButton title='Follow' size={20} icon='user-plus' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
</div>
);

View File

@@ -17,9 +17,9 @@ const NavigationBar = React.createClass({
<div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
<Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link>
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>
<a href='/settings' style={{ color: '#9baec8', textDecoration: 'none' }}>Settings <i className='fa fa fa-cog' /></a>
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}>Settings</a> · <Link to='/statuses/all' style={{ color: 'inherit', textDecoration: 'none' }}>Public timeline</Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}>Logout</a>
</div>
</div>
);

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 ComposeForm from '../components/compose_form';
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
import { selectStatus } from '../../../reducers/timelines';
import { getStatus } from '../../../selectors';
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
};
};

View File

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

View File

@@ -8,14 +8,18 @@ import {
unfavourite
} from '../../../actions/interactions';
import { expandTimeline } from '../../../actions/timelines';
import { selectStatus } from '../../../reducers/timelines';
import { makeGetTimeline } from '../../../selectors';
import { deleteStatus } from '../../../actions/statuses';
const mapStateToProps = function (state, props) {
return {
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
const makeMapStateToProps = () => {
const getTimeline = makeGetTimeline();
const mapStateToProps = (state, props) => ({
statuses: getTimeline(state, props.type),
me: state.getIn(['timelines', 'me'])
};
});
return mapStateToProps;
};
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 Column from './components/column';
import Drawer from './components/drawer';
import ComposeFormContainer from './containers/compose_form_container';
import FollowFormContainer from './containers/follow_form_container';
import UploadFormContainer from './containers/upload_form_container';
import StatusListContainer from './containers/status_list_container';
import NotificationsContainer from './containers/notifications_container';
import NavigationContainer from './containers/navigation_container';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import LoadingBarContainer from './containers/loading_bar_container';
import HomeTimeline from '../home_timeline';
import MentionsTimeline from '../mentions_timeline';
import Compose from '../compose';
import MediaQuery from 'react-responsive';
import TabsBar from './components/tabs_bar';
const UI = React.createClass({
propTypes: {
router: React.PropTypes.object
},
mixins: [PureRenderMixin],
render () {
const layoutBreakpoint = 1024;
return (
<div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}>
<Drawer>
<div style={{ flex: '1 1 auto' }}>
<NavigationContainer />
<ComposeFormContainer />
<UploadFormContainer />
</div>
<div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', width: '100%', height: '100%', background: '#1a1c23' }}>
<MediaQuery maxWidth={layoutBreakpoint}>
<TabsBar />
</MediaQuery>
<FollowFormContainer />
</Drawer>
<MediaQuery maxWidth={layoutBreakpoint} component={ColumnsArea}>
{this.props.children}
</MediaQuery>
<ColumnsArea>
<Column icon='home' heading='Home'>
<StatusListContainer type='home' />
</Column>
<Column icon='at' heading='Mentions'>
<StatusListContainer type='mentions' />
</Column>
<Column>
<MediaQuery minWidth={layoutBreakpoint}>
<ColumnsArea>
<Compose />
<HomeTimeline />
<MentionsTimeline />
{this.props.children}
</Column>
</ColumnsArea>
</ColumnsArea>
</MediaQuery>
<NotificationsContainer />
<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 {
REBLOG_FAIL,
UNREBLOG_FAIL,
FAVOURITE_FAIL,
UNFAVOURITE_FAIL
} from '../actions/interactions';
import {
TIMELINE_REFRESH_FAIL,
TIMELINE_EXPAND_FAIL
} from '../actions/timelines';
import { NOTIFICATION_DISMISS, NOTIFICATION_CLEAR } from '../actions/notifications';
import {
ACCOUNT_FETCH_FAIL,
ACCOUNT_FOLLOW_FAIL,
ACCOUNT_UNFOLLOW_FAIL,
ACCOUNT_TIMELINE_FETCH_FAIL,
ACCOUNT_TIMELINE_EXPAND_FAIL
} from '../actions/accounts';
import {
STATUS_FETCH_FAIL,
STATUS_DELETE_FAIL
} from '../actions/statuses';
import Immutable from 'immutable';
NOTIFICATION_SHOW,
NOTIFICATION_DISMISS,
NOTIFICATION_CLEAR
} from '../actions/notifications';
import Immutable from 'immutable';
const initialState = Immutable.List();
function notificationFromError(state, error) {
let n = Immutable.Map({
key: state.size > 0 ? state.last().get('key') + 1 : 0,
message: ''
});
if (error.response) {
n = n.withMutations(map => {
map.set('message', error.response.statusText);
map.set('title', `${error.response.status}`);
});
} else {
n = n.set('message', `${error}`);
}
return state.push(n);
};
const initialState = Immutable.List([]);
export default function notifications(state = initialState, action) {
switch(action.type) {
case COMPOSE_SUBMIT_FAIL:
case COMPOSE_UPLOAD_FAIL:
case FOLLOW_SUBMIT_FAIL:
case REBLOG_FAIL:
case FAVOURITE_FAIL:
case TIMELINE_REFRESH_FAIL:
case TIMELINE_EXPAND_FAIL:
case ACCOUNT_FETCH_FAIL:
case ACCOUNT_FOLLOW_FAIL:
case ACCOUNT_UNFOLLOW_FAIL:
case ACCOUNT_TIMELINE_FETCH_FAIL:
case ACCOUNT_TIMELINE_EXPAND_FAIL:
case STATUS_FETCH_FAIL:
case STATUS_DELETE_FAIL:
case UNREBLOG_FAIL:
case UNFAVOURITE_FAIL:
return notificationFromError(state, action.error);
case NOTIFICATION_SHOW:
return state.push(Immutable.Map({
key: state.size > 0 ? state.last().get('key') + 1 : 0,
title: action.title,
message: action.message
}));
case NOTIFICATION_DISMISS:
return state.filterNot(item => item.get('key') === action.notification.key);
case NOTIFICATION_CLEAR:

View File

@@ -25,53 +25,30 @@ import {
STATUS_DELETE_SUCCESS
} from '../actions/statuses';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import Immutable from 'immutable';
const initialState = Immutable.Map({
home: Immutable.List([]),
mentions: Immutable.List([]),
public: Immutable.List([]),
statuses: Immutable.Map(),
accounts: Immutable.Map(),
accounts_timelines: Immutable.Map(),
me: null,
ancestors: 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) {
// Separate account
let account = status.get('account');
status = status.set('account', account.get('id'));
// Separate reblog, repeat for reblog
let reblog = status.get('reblog');
let reblog = status.get('reblog', null);
if (reblog !== null) {
status = status.set('reblog', reblog.get('id'));
@@ -101,16 +78,18 @@ function normalizeStatus(state, status) {
};
function normalizeTimeline(state, timeline, statuses) {
let ids = Immutable.List([]);
statuses.forEach((status, i) => {
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) {
let moreIds = Immutable.List();
let moreIds = Immutable.List([]);
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
@@ -121,29 +100,44 @@ function appendNormalizedTimeline(state, timeline, 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) => {
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;
};
function appendNormalizedAccountTimeline(state, accountId, statuses) {
let moreIds = Immutable.List();
let moreIds = Immutable.List([]);
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.push(...moreIds));
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
};
function updateTimeline(state, timeline, status) {
state = normalizeStatus(state, status);
state = state.update(timeline, list => list.unshift(status.get('id')));
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List(), list => list.unshift(status.get('id')));
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;
};
@@ -161,7 +155,7 @@ function deleteStatus(state, id) {
});
// Remove references from account timelines
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id));
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove reblogs of deleted status
const references = state.get('statuses').filter(item => item.get('reblog') === id);
@@ -183,6 +177,10 @@ function normalizeAccount(state, account, 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);
};
@@ -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) {
switch(action.type) {
case TIMELINE_REFRESH_SUCCESS:
@@ -242,6 +248,8 @@ export default function timelines(state = initialState, action) {
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case SUGGESTIONS_FETCH_SUCCESS:
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
default:
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 appReducer from '../reducers';
import { loadingBarMiddleware } from 'react-redux-loading-bar';
import errorsMiddleware from '../middleware/errors';
export default function configureStore(initialState) {
return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
})), window.devToolsExtension ? window.devToolsExtension() : f => f));
}), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
};

View File

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

View File

@@ -57,6 +57,43 @@ table {
border-spacing: 0;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #42495b;
border: 0px none #ffffff;
border-radius: 50px;
}
::-webkit-scrollbar-thumb:hover {
background: #525a70;
}
::-webkit-scrollbar-thumb:active {
background: #42495b;
}
::-webkit-scrollbar-track {
border: 0px none #ffffff;
border-radius: 0;
background: rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-track:hover {
background: #282c37;
}
::-webkit-scrollbar-track:active {
background: #282c37;
}
::-webkit-scrollbar-corner {
background: transparent;
}
body {
font-family: 'Roboto', sans-serif;
background: #282c37 image-url('background-photo.jpeg');
@@ -152,173 +189,6 @@ body {
}
}
.form-container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
.field {
margin-bottom: 15px;
}
.file-field {
padding: 15px 0;
label {
font-family: 'Roboto';
font-size: 16px;
color: #fff;
width: 100px;
display: inline-block;
}
input[type=file] {
width: 280px;
}
}
input[type=text], input[type=email], input[type=password], textarea {
background: transparent;
border: 0;
border-bottom: 2px solid #9baec8;
padding: 7px 0;
font-size: 16px;
color: #fff;
display: block;
width: 100%;
outline: 0;
font-family: 'Roboto';
&:invalid {
box-shadow: none;
}
&:focus:invalid {
border-bottom-color: #df405a;
}
&:required:valid {
border-bottom-color: #79bd9a;
}
&:active, &:focus {
border-bottom-color: #2b90d9;
}
}
.field_with_error {
input[type=text], input[type=email], input[type=password] {
border-bottom-color: #df405a;
}
}
.prompt {
font-size: 16px;
color: #9baec8;
text-align: center;
.prompt-highlight {
font-weight: 500;
color: #fff;
}
}
code.copypasteable {
display: block;
font-family: 'Roboto Mono', monospace;
font-weight: 400;
font-size: 12px;
margin-top: 20px;
background: #282c37;
border-radius: 4px;
padding: 2px;
word-wrap: break-word;
}
.actions {
margin-top: 30px;
button {
display: block;
width: 100%;
border: 0;
border-radius: 4px;
background: #2b90d9;
color: #fff;
font-size: 18px;
padding: 10px;
text-transform: uppercase;
cursor: pointer;
font-weight: 500;
outline: 0;
margin-bottom: 10px;
&:hover {
background-color: lighten(#2b90d9, 5%);
}
&:active, &:focus {
position: relative;
top: 1px;
background-color: darken(#2b90d9, 5%);
}
&.negative {
background: #df405a;
&:hover {
background-color: lighten(#df405a, 5%);
}
&:active, &:focus {
background-color: darken(#df405a, 5%);
}
}
}
}
.flash-message {
text-align: center;
font-size: 14px;
margin-bottom: 30px;
font-weight: 500;
}
.form-footer {
margin-top: 30px;
text-align: center;
a {
color: #9baec8;
text-decoration: none;
&:hover {
color: #d9e1e8;
text-decoration: underline;
}
}
}
#error_explanation {
background: #282c37;
color: #9baec8;
border-radius: 4px;
padding: 15px 10px;
margin-bottom: 30px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
h2 {
font-weight: 500;
margin-bottom: 5px;
}
li {
margin-left: 15px;
list-style: circle;
}
}
}
.no-list {
list-style: none;
@@ -359,6 +229,7 @@ body {
}
}
@import 'forms';
@import 'accounts';
@import 'stream_entries';
@import 'components';

View File

@@ -71,6 +71,7 @@
line-height: 20px;
word-wrap: break-word;
font-weight: 300;
overflow: hidden;
p {
margin-bottom: 20px;
@@ -197,7 +198,7 @@
font-size: 13px;
display: block;
padding: 6px 16px;
width: 120px;
width: 100px;
text-decoration: none;
background: #d9e1e8;
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 {
border-radius: 4px 4px 0 0;
.entry:first-child {
border-radius: 4px 4px 0 0;
&:last-child {
border-radius: 4px;
}
&:last-child {
border-radius: 4px;
}
}
@@ -75,19 +73,14 @@
}
}
@media screen and (max-width: 360px) {
.avatar {
display: none;
}
}
.entry__container {
display: flex;
overflow: hidden;
}
.avatar {
width: 56px;
padding: 15px;
float: left;
img {
width: 56px;
@@ -98,7 +91,7 @@
}
.entry__container__container {
flex-grow: 1;
margin-left: 86px;
}
.header {
@@ -153,10 +146,12 @@
.content {
font-size: 14px;
padding: 0 10px;
padding: 0 15px;
padding-left: 8px;
padding-bottom: 15px;
color: #282c37;
word-wrap: break-word;
overflow: hidden;
p {
margin-bottom: 18px;
@@ -224,4 +219,14 @@
}
}
}
@media screen and (max-width: 360px) {
.avatar {
display: none;
}
.entry__container__container {
margin-left: 7px;
}
}
}

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
def follow
FollowService.new.call(current_user.account, @account.acct)
redirect_to account_path(@account)
end
def unfollow
UnfollowService.new.call(current_user.account, @account)
redirect_to account_path(@account)
end
def followers
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 6)
end

View File

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

View File

@@ -13,8 +13,9 @@ class Api::SubscriptionsController < ApiController
def update
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)
head 201
else

View File

@@ -1,6 +1,6 @@
class Api::V1::AccountsController < ApiController
before_action :doorkeeper_authorize!
before_action :set_account, except: :verify_credentials
before_action :set_account, except: [:verify_credentials, :suggestions]
respond_to :json
def show
@@ -19,8 +19,13 @@ class Api::V1::AccountsController < ApiController
@followers = @account.followers
end
def suggestions
@accounts = FollowSuggestion.get(current_user.account_id)
end
def statuses
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
end
def follow
@@ -49,7 +54,7 @@ class Api::V1::AccountsController < ApiController
def relationships
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)
@followed_by = Account.followed_by_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
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
end
end

View File

@@ -4,5 +4,9 @@ class Api::V1::MediaController < ApiController
def create
@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

View File

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

View File

@@ -35,4 +35,10 @@ class ApiController < ApplicationController
def render_empty
render json: {}, status: 200
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

View File

@@ -10,7 +10,7 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::RoutingError, 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
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"

View File

@@ -17,6 +17,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def after_sign_up_path_for(_resource)
root_path
new_user_session_path
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'
before_action :authenticate_user!
@@ -9,7 +9,7 @@ class SettingsController < ApplicationController
def update
if @account.update(account_params)
redirect_to settings_path
redirect_to settings_profile_path, notice: 'Changes successfully saved!'
else
render action: :show
end

View File

@@ -1,27 +1,40 @@
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
@webfinger_template = "#{webfinger_url}?resource={uri}"
respond_to do |format|
format.xml { render content_type: 'application/xrd+xml' }
end
end
def webfinger
@account = Account.find_local!(username_from_resource)
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
@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
head 404
end
private
def set_format
request.format = 'xml'
response.headers['Content-Type'] = 'application/xrd+xml'
def set_default_format_xml
request.format = 'xml' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
end
def set_default_format_json
request.format = 'json' if request.headers["HTTP_ACCEPT"].nil? && params[:format].nil?
end
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:', '')
else
url = Addressable::URI.parse(resource_param)

View File

@@ -149,6 +149,7 @@ module AtomBuilderHelper
verb xml, stream_entry.verb
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)
object_type xml, stream_entry.object_type
# Comments need thread element
if stream_entry.threaded?
@@ -157,17 +158,18 @@ module AtomBuilderHelper
if stream_entry.targeted?
target(xml) do
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
if stream_entry.target.object_type == :person
include_author xml, stream_entry.target
else
object_type xml, stream_entry.target.object_type
simple_id xml, TagManager.instance.uri_for(stream_entry.target)
title xml, stream_entry.target.title
link_alternate xml, TagManager.instance.url_for(stream_entry.target)
end
# 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)
verb xml, stream_entry.target.verb
published_at xml, stream_entry.target.created_at
@@ -176,10 +178,16 @@ module AtomBuilderHelper
author(xml) do
include_author xml, stream_entry.target.account
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
else
object_type xml, stream_entry.object_type
end
stream_entry.mentions.each do |mentioned|

View File

@@ -2,13 +2,7 @@ module HomeHelper
def default_props
{
token: @token,
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)
}
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json)
}
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}")
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)
rabl_scope = Class.new do
include RoutingHelper
@@ -58,7 +42,7 @@ class FeedManager
end
def current_user
@account.user
@account.try(:user)
end
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
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

View File

@@ -29,7 +29,9 @@ class Formatter
end
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
def link_mentions(html, mentions)

View File

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

View File

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

View File

@@ -24,10 +24,10 @@ class Account < ApplicationRecord
validates :note, length: { maximum: 124 }, if: 'local?'
# Timelines
has_many :stream_entries, inverse_of: :account
has_many :statuses, inverse_of: :account
has_many :favourites, inverse_of: :account
has_many :mentions, inverse_of: :account
has_many :stream_entries, inverse_of: :account, dependent: :destroy
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy
# Follow relations
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 :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)
active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
end
@@ -125,7 +127,7 @@ class Account < ApplicationRecord
end
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
def self.find_local(username)

View File

@@ -4,8 +4,8 @@ module Paginable
included do
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
query = order('id desc').limit(limit)
query = query.where('id < ?', max_id) unless max_id.blank?
query = query.where('id > ?', since_id) unless since_id.blank?
query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
query
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
destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
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

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
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 :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_size :file, less_than: 4.megabytes
@@ -30,4 +33,31 @@ class MediaAttachment < ApplicationRecord
def type
image? ? 'image' : 'video'
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

View File

@@ -2,7 +2,7 @@ class Status < ApplicationRecord
include Paginable
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 :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 :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_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
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)
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
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
before_validation do

View File

@@ -39,7 +39,7 @@ class StreamEntry < ApplicationRecord
end
def threaded?
verb == :favorite || object_type == :comment
(verb == :favorite || object_type == :comment) && !thread.nil?
end
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 :recent, -> { order('created_at desc') }
scope :admins, -> { where(admin: true) }
has_settings do |s|
s.key :notification_emails, defaults: { follow: true, reblog: true, favourite: true, mention: true }
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_followers(status)
deliver_to_mentioned(status)
deliver_to_public(status)
end
private
@@ -27,4 +28,8 @@ class FanOutOnWriteService < BaseService
FeedManager.instance.push(:mentions, mentioned_account, status)
end
end
def deliver_to_public(status)
FeedManager.instance.broadcast(:public, id: status.id)
end
end

View File

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

View File

@@ -1,4 +1,8 @@
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.
# When creating, look up the user's webfinger and fetch all
# important information from their feed
@@ -7,7 +11,8 @@ class FollowRemoteAccountService < BaseService
def call(uri)
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)
@@ -18,27 +23,21 @@ class FollowRemoteAccountService < BaseService
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.salmon_url = data.link('salmon').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.private_key = nil
feed = get_feed(account.remote_url)
hubs = feed.xpath('//xmlns:link[@rel="hub"]')
xml = get_feed(account.remote_url)
hubs = get_hubs(xml)
if hubs.empty? || hubs.first.attribute('href').nil?
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.uri = get_account_uri(xml)
account.hub_url = hubs.first.attribute('href').value
get_profile(feed, account)
get_profile(xml, account)
account.save!
return account
@@ -51,20 +50,27 @@ class FollowRemoteAccountService < BaseService
Nokogiri::XML(response)
end
def get_profile(xml, account)
author = xml.at_xpath('/xmlns:feed/xmlns:author')
update_remote_profile_service.call(author, account)
def get_hubs(xml)
hubs = xml.xpath('//xmlns:link[@rel="hub"]')
raise Goldfinger::Error, 'No PubSubHubbub hubs found' if hubs.empty? || hubs.first.attribute('href').nil?
hubs
end
def magic_key_to_pem(magic_key)
_, modulus, exponent = magic_key.split('.')
modulus, exponent = [modulus, exponent].map { |n| Base64.urlsafe_decode64(n).bytes.inject(0) { |a, e| (a << 8) | e } }
def get_account_uri(xml)
author_uri = xml.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri')
key = OpenSSL::PKey::RSA.new
key.n = modulus
key.e = exponent
if author_uri.nil?
owner = xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
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
def update_remote_profile_service

View File

@@ -5,7 +5,7 @@ class FollowService < BaseService
def call(source_account, 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)

View File

@@ -1,4 +1,7 @@
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
# @param [String] body Atom feed
# @param [Account] account Account this feed belongs to
@@ -34,23 +37,27 @@ class ProcessFeedService < BaseService
else
add_reply!(entry, status)
end
else
return
end
# 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
unless status.new_record?
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.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)
return status
end
return status
end
def record_remote_mentions(status, links)
return if status.local?
# 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
# 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)
if href.host == Rails.configuration.x.local_domain
if TagManager.instance.local_domain?(href.host)
# A local user is mentioned
mentioned_account = Account.find_local(href.path.gsub('/users/', ''))
@@ -88,6 +95,8 @@ class ProcessFeedService < BaseService
end
def process_attachments(entry, status)
return if status.local?
entry.xpath('./xmlns:link[@rel="enclosure"]').each do |enclosure_link|
next if enclosure_link.attribute('href').nil?
@@ -95,9 +104,14 @@ class ProcessFeedService < BaseService
next unless media.nil?
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
media.file_remote_url = enclosure_link.attribute('href').value
media.save
begin
media = MediaAttachment.new(account: status.account, status: status, remote_url: enclosure_link.attribute('href').value)
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
@@ -142,10 +156,10 @@ class ProcessFeedService < BaseService
end
def fetch_remote_status(xml)
username = xml.at_xpath('./activity:object/xmlns:author/xmlns:name').content
url = xml.at_xpath('./activity:object/xmlns:author/xmlns:uri').content
username = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:name').content
url = xml.at_xpath('./activity:object', activity: ACTIVITY_NS).at_xpath('./xmlns:author/xmlns:uri').content
domain = Addressable::URI.parse(url).host
account = Account.find_by(username: username, domain: domain)
account = Account.find_remote(username, domain)
if account.nil?
account = follow_remote_account_service.call("#{username}@#{domain}")
@@ -172,23 +186,23 @@ class ProcessFeedService < BaseService
end
def content(xml)
xml.at_xpath('./xmlns:content').content
xml.at_xpath('./xmlns:content').try(:content)
end
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
nil
end
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
nil
end
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
nil
end
@@ -204,21 +218,21 @@ class ProcessFeedService < BaseService
end
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
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
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
:note
:activity
end
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
:post
end

View File

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

View File

@@ -34,7 +34,8 @@ class RemoveStatusService < BaseService
end
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
def remove_reblogs(status)

View File

@@ -1,13 +1,17 @@
class UpdateRemoteProfileService < BaseService
POCO_NS = 'http://portablecontacts.net/spec/1.0'
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
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
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
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
%h1
= image_tag 'logo.png'
@@ -16,4 +19,5 @@
is a Mastodon instance.
.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)})" }
- 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)
%h1.name
= display_name(@account)

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