Compare commits

..

231 Commits
v0.7 ... v0.9

Author SHA1 Message Date
Eugen Rochko
bf5f55a6bb Fix live status removal from public/hashtag channels 2016-11-10 00:47:47 +01:00
Eugen Rochko
aabf884c5f Discard misattributed remote statuses, improve timelines filter 2016-11-10 00:15:49 +01:00
Eugen Rochko
17903c6dae Improve filtering of public/hashtag timelines, both in backlog and real-time 2016-11-10 00:03:33 +01:00
Eugen Rochko
c5e03a2e0d Status removal is broadcast to public/hashtag timelines too 2016-11-09 19:16:27 +01:00
Eugen Rochko
448ab18a20 Replace setting custom CORS headers with rack-cors, set it on /oauth/token endpoint 2016-11-09 18:06:01 +01:00
Eugen Rochko
b13e7dda1f API pagination for all collections using Link header 2016-11-09 17:48:44 +01:00
Eugen Rochko
8d7fc5da6c Update README with attribution links and FAQ link 2016-11-08 23:33:59 +01:00
Eugen Rochko
9aecc0f48a Move timelines API from statuses to its own controller, add a check for
resources that require a user context vs those that don't (such as public timeline)

/api/v1/statuses/public   -> /api/v1/timelines/public
/api/v1/statuses/home     -> /api/v1/timelines/home
/api/v1/statuses/mentions -> /api/v1/timelines/mentions
/api/v1/statuses/tag/:tag -> /api/v1/timelines/tag/:tag
2016-11-08 23:29:08 +01:00
Eugen Rochko
86574ea524 Adding Emoji One 2016-11-08 21:46:29 +01:00
Eugen Rochko
b6832553ff Adding more logging 2016-11-08 19:37:08 +01:00
Eugen Rochko
cff0b03cbb Fix for thread resolve service and process feed service url method 2016-11-08 19:09:22 +01:00
Eugen Rochko
2f21f4cc01 Fix region setting for AWS gem 2016-11-08 18:55:46 +01:00
Eugen Rochko
2d69bf4b3a Fix 50x page image for non-/ paths 2016-11-08 02:18:09 +01:00
Eugen Rochko
bb4d1eb2e8 Improve feed regeneration 2016-11-08 02:08:32 +01:00
Eugen Rochko
096bfbad96 Fix typo 2016-11-08 01:48:17 +01:00
Eugen Rochko
11cbe49ffc ProcessFeedService refactor 2016-11-08 01:44:51 +01:00
Eugen Rochko
dbe00a4156 Improved configuration from ENV, cleaned up timeline filter methods
to be more readable, add extra logging to process feed service
2016-11-07 23:20:52 +01:00
Eugen Rochko
0400734df7 Click video player to toggle playback 2016-11-07 19:05:32 +01:00
Eugen Rochko
a39a92bd22 Fix #135 - More prominent focus style for inputs 2016-11-07 18:45:44 +01:00
Eugen Rochko
bec47e40f5 Better look/behaviours for video player/lightbox 2016-11-07 18:42:39 +01:00
Eugen Rochko
d0d799f911 Replace react-skylight with own solution that centers images better 2016-11-07 18:23:36 +01:00
Eugen Rochko
f5c6baf29d Fix account and hashtag regex 2016-11-07 17:35:25 +01:00
Eugen Rochko
c3559d18a3 Do not modify scroll position unless content has been added to the top, not bottom 2016-11-07 02:11:38 +01:00
Eugen Rochko
4a6cc46e81 Keep scroll position when scrolled down and new content is added 2016-11-07 02:02:55 +01:00
Eugen Rochko
23fc424b7a Account notes (bios) can now contain links. Character limit upped to 160 2016-11-07 01:14:12 +01:00
Eugen Rochko
b835f4aa1c Fix insecure S3 URLs 2016-11-06 20:59:06 +01:00
Eugen Rochko
ea4170785a Downgrade aws gem as this one was incompatible with Paperclip 2016-11-06 20:55:57 +01:00
Eugen Rochko
d14967e1c8 Fix URL configuration when S3 is enabled 2016-11-06 20:43:16 +01:00
Eugen Rochko
45230c56ab Improve S3 config 2016-11-06 18:55:20 +01:00
Eugen Rochko
3ab193bc3f Adding optional S3, fail-mastodon 2016-11-06 18:35:46 +01:00
Eugen Rochko
93212bc2c4 Add test for FanOutOnWriteService 2016-11-06 15:56:34 +01:00
Eugen Rochko
7bb28bf780 Improve @mention regex 2016-11-05 22:09:51 +01:00
Eugen Rochko
262adb9791 Fix remote hashtag linking 2016-11-05 17:54:19 +01:00
Eugen Rochko
eb47409036 Fix mistake 2016-11-05 17:48:53 +01:00
Eugen Rochko
a698b767c1 Fix linking of remote hashtags in UI, add public view of hashtags 2016-11-05 17:44:14 +01:00
Eugen Rochko
48b9619439 Adding hashtags 2016-11-05 17:13:14 +01:00
Eugen Rochko
62292797ec Adding hashtag model 2016-11-04 19:12:59 +01:00
Eugen Rochko
6471a548fe Add pghero for analyzing postgres performance in production 2016-11-04 18:35:04 +01:00
Eugen Rochko
4ea13d8dc9 Fix web app manifests 2016-11-04 14:19:58 +01:00
Eugen Rochko
2463c72006 Add forgotten reducer for FAVOURITES_FETCH_SUCCESS 2016-11-04 14:15:25 +01:00
Eugen Rochko
087b993892 Adding list of who favourited status 2016-11-04 13:39:24 +01:00
Eugen Rochko
6d5ef89356 Reduce code duplication, add touch scrolling behaviour for webkit browsers
on scrollable areas
2016-11-04 13:32:14 +01:00
Eugen Rochko
98c3a5e9c3 Optimize how statuses are re-rendered and relative time intervals 2016-11-04 12:48:53 +01:00
Eugen Rochko
6d26bfd147 Add list of who reblogged status 2016-11-03 20:16:14 +01:00
Eugen Rochko
0405ef977a Aria-labels actually didn't work in React, so removing. Fixing some bugs 2016-11-03 19:52:09 +01:00
Eugen Rochko
159203a7bc Fix #136: Add aria-labels 2016-11-03 19:19:39 +01:00
Eugen Rochko
1828df9bc0 Disable accounts caching too 2016-11-03 19:06:11 +01:00
Eugen Rochko
abf664b560 Disable caching of statuses (maybe this will help with the weird bug) 2016-11-03 18:55:55 +01:00
Eugen Rochko
829d189f44 Add tests for recently added API end points 2016-11-03 18:53:37 +01:00
Eugen Rochko
3731230c6d Allow @username@domain/@username in follow form, prevent duplicate accounts
created via remote look-up when domains differ but point to the same resource
2016-11-03 16:57:44 +01:00
Eugen Rochko
e4671adc25 Fix reblogged/favourited caching; add API endpoints for who favd/reblogged status 2016-11-03 14:50:22 +01:00
Eugen Rochko
c003e70758 Caching should work correctly now (fingers crossed) 2016-11-03 14:12:45 +01:00
Eugen Rochko
562044f36a Need to disable caching again due to bug in Rabl 2016-11-03 13:59:31 +01:00
Eugen Rochko
0160d1d9b5 Enable caching for some rabl views 2016-11-03 13:28:36 +01:00
Eugen Rochko
aff22bfdb5 Add own composed status to home without waiting for websocket event
This makes the UI appear less laggy. After all, the status *is* created
right there and then, it's the distribution to home timelines that can take
a while
2016-11-03 11:06:55 +01:00
Eugen Rochko
4b357ecf98 Fix subtle bugs, new icon button 2016-11-02 22:29:19 +01:00
Eugen
d427df4a8a Merge pull request #132 from marcus-herrmann/master
a11y: Improve icon button component regarding semantics, screen reader usage
2016-11-02 21:53:05 +01:00
Eugen
76a717d549 Merge pull request #129 from fhemberger/fix/a11y-labels
a11y: Add labels to input fields for screenreader
2016-11-02 21:53:00 +01:00
mhe
87aabeb7af a11y: Improve icon button component regarding semantics and screen reader usage 2016-11-02 20:18:39 +01:00
Eugen
77045d0886 Merge pull request #130 from fhemberger/feature/manifest
Add touch icons for Chrome, iOS and Windows 10
2016-11-02 17:41:32 +01:00
Eugen
bc1dba22c5 Fix syntax errors 2016-11-02 17:13:47 +01:00
Eugen Rochko
e7723ab662 Add puma preloading, decrease DB pool size to size of threads per worker 2016-11-02 16:38:54 +01:00
Frederic Hemberger
e7cd6da938 Add touch icons for Chrome, iOS and Windows 10 2016-11-02 16:05:08 +01:00
Frederic Hemberger
5ff2762fcf a11y: Add labels to input fields for screenreader
See: https://dequeuniversity.com/rules/axe/2.1/label
2016-11-02 14:53:39 +01:00
Eugen Rochko
53fb7b1aa8 Replace in-memory cache store with redis store, should decrease memory
footprint of each running process by 128mb
2016-11-02 13:30:52 +01:00
Eugen Rochko
a59d10d3df Fix missing default headers 2016-11-02 13:12:18 +01:00
Eugen Rochko
9467b900a2 Make cookies https-only if LOCAL_HTTPS is true, set X-Frame-Options to DENY,
add permissive CORS to API controllers
2016-11-02 12:58:15 +01:00
Eugen
0a6b5e2c17 Merge pull request #125 from fhemberger/fix/a11y-language
a11y: Add HTML lang attribute and utf8 encoding meta tag
2016-11-02 11:04:27 +01:00
Frederic Hemberger
d991869498 a11y: Add HTML lang attribute and utf8 encoding meta tag
See: https://dequeuniversity.com/rules/axe/2.1/html-has-lang
2016-11-02 11:01:18 +01:00
Eugen Rochko
dd03118098 Fix follow icon changing plus to minus, add terms page stub 2016-11-01 18:05:55 +01:00
Eugen Rochko
57304ac375 Catch 404 errors when downloading remote media, add screenshot to landing page 2016-11-01 16:58:13 +01:00
Eugen Rochko
bab4904492 Actually fix the reblogs/favs reducer issue 2016-10-31 18:11:49 +01:00
Eugen
d88d148d89 Fix forgotten reducers for reblogs/favourites 2016-10-31 16:59:36 +01:00
Eugen
3692051561 Updated screenshot 2016-10-31 12:39:41 +01:00
Eugen
50aaf4667f Fix accounts not being normalized from context statuses 2016-10-31 01:27:19 +01:00
Eugen Rochko
7e35650398 Actually complete the autosuggestion feature 2016-10-30 18:21:38 +01:00
Eugen Rochko
c49f6290eb Basic username autocomplete for text area 2016-10-30 18:13:05 +01:00
Eugen Rochko
fa1cc2d05a Limit returned followees/followers by API to 40 for now 2016-10-30 15:14:07 +01:00
Eugen Rochko
f10feb2e70 Fix common followers API 2016-10-30 15:08:36 +01:00
Eugen Rochko
e8ff4c8e56 Refactoring redux state into different reducers 2016-10-30 15:06:43 +01:00
Eugen Rochko
7060bdf04b Hide notes on user lists for now 2016-10-29 01:31:33 +02:00
Eugen Rochko
e0a197650a Adding common followers API, fixing fallback query again 2016-10-29 01:29:19 +02:00
Eugen Rochko
c913bdfc98 Fix follow suggestion fallback to exclude already followed users 2016-10-28 20:35:30 +02:00
Eugen Rochko
bfb6cc5f2c Make suggestions box also use user list components 2016-10-28 20:26:49 +02:00
Eugen Rochko
ac4f53a3a2 Improved how user lists look, added follow button to them 2016-10-28 20:05:44 +02:00
Eugen Rochko
1c84d505c8 Adding following/followers lists to the UI 2016-10-27 21:59:56 +02:00
Eugen Rochko
909d0d5e88 Adding public timeline silencing 2016-10-27 19:33:04 +02:00
Eugen Rochko
e5565a7e4a Fix suggestions fallback query 2016-10-27 19:03:05 +02:00
Eugen Rochko
19615a2c37 Add restart policy to docker compose 2016-10-25 13:21:48 +02:00
Eugen Rochko
c1a0633a12 Guard against unexisting statuses rendering 2016-10-25 11:13:16 +02:00
Eugen Rochko
de50eff6ac Add opening images in a modal window 2016-10-24 18:08:23 +02:00
Eugen Rochko
f8f40f15da Move status components inside individual containers. We still need to select
all statuses/accounts to assemble, but at least lists don't have to be
re-rendered all the time now. Also add "mention" dropdown option
2016-10-24 18:08:23 +02:00
Eugen
61db14bcbe Add more useful links to the readme 2016-10-24 12:37:06 +02:00
Eugen Rochko
720d1f8f3d Restrict access to oauth/applications to admins only 2016-10-23 12:08:52 +02:00
Eugen Rochko
b746a931a5 Fix OAuth authorization redirect 2016-10-23 12:05:55 +02:00
Eugen Rochko
abb8f5837e Fix public channel 2016-10-23 11:56:04 +02:00
Eugen Rochko
a9e40a3d80 Adding OAuth access scopes, fixing OAuth authorization UI, adding rate limiting
to the API
2016-10-22 19:39:44 +02:00
Eugen
17122df80d Merge pull request #101 from kschaper/master
Add specs for Button, DisplayName, DropdownMenu components
2016-10-20 18:39:30 +02:00
Eugen Rochko
852c82435d Fix method return when rescuing 2016-10-20 18:36:12 +02:00
Kai Schaper
4b0e3eb1fb Merge remote-tracking branch 'upstream/master' 2016-10-20 17:35:05 +02:00
Kai Schaper
19cfe64455 add/update specs for Button, DisplayName, DropdownMenu 2016-10-20 17:34:44 +02:00
Eugen Rochko
04bbc57690 Fix #100 - Add "back" button to certain views
Also fix reloading of timelines after merge-type events
2016-10-19 18:20:19 +02:00
Eugen Rochko
8698cd3281 Remember scroll position when navigating back, do not needlessly reload
entire timelines (only fetch since last known ID). Side effect: account
timelines no longer update in real-time
2016-10-18 23:06:28 +02:00
Eugen Rochko
1d2175f73c Do not force-transcode uploaded videos to webm (leave mp4 as-is) 2016-10-18 17:51:45 +02:00
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
2107 changed files with 10718 additions and 1607 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,14 +1,60 @@
Rails:
Enabled: true
Metrics/LineLength:
Enabled: false
Style/PerlBackrefs:
AutoCorrect: false
Style/ClassAndModuleChildren:
Enabled: false
Documentation:
Metrics/BlockNesting:
Max: 2
Metrics/LineLength:
AllowURI: true
Enabled: false
Metrics/MethodLength:
CountComments: false
Max: 10
Metrics/ModuleLength:
Max: 100
Metrics/ParameterLists:
Max: 4
CountKeywordArgs: true
Style/AccessModifierIndentation:
EnforcedStyle: indent
Style/CollectionMethods:
Enabled: true
PreferredMethods:
find_all: 'select'
Style/Documentation:
Enabled: false
Style/DoubleNegation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/SpaceInsideHashLiteralBraces:
EnforcedStyle: space
Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: 'comma'
Style/RegexpLiteral:
Enabled: false
AllCops:
TargetRubyVersion: 2.2
Exclude:
- 'spec/**/*'
- 'db/**/*'
- 'app/views/**/*'
- 'config/**/*'

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

@@ -4,7 +4,6 @@ gem 'rails', '5.0.0.1'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0'
gem 'therubyracer', platforms: :ruby
gem 'jquery-rails'
gem 'jbuilder', '~> 2.0'
gem 'sdoc', '~> 0.4.0', group: :doc
@@ -12,11 +11,13 @@ gem 'puma'
gem 'hamlit-rails'
gem 'pg'
gem 'pghero'
gem 'dotenv-rails'
gem 'font-awesome-rails'
gem 'paperclip', '~> 4.3'
gem 'paperclip-av-transcoder'
gem 'aws-sdk', '< 2.0'
gem 'http'
gem 'httplog'
@@ -37,10 +38,14 @@ gem 'htmlentities'
gem 'simple_form'
gem 'will_paginate'
gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'
gem 'sidekiq'
gem 'ledermann-rails-settings'
gem 'neography'
gem 'react-rails'
gem 'browserify-rails'
gem 'autoprefixer-rails'
group :development, :test do
gem 'rspec-rails'
@@ -67,4 +72,5 @@ end
group :production do
gem 'rails_12factor'
gem 'lograge'
gem 'redis-rails'
end

View File

@@ -42,8 +42,15 @@ GEM
addressable (2.4.0)
arel (7.1.1)
ast (2.3.0)
autoprefixer-rails (6.5.0.2)
execjs
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (1.66.0)
aws-sdk-v1 (= 1.66.0)
aws-sdk-v1 (1.66.0)
json (~> 1.4)
nokogiri (>= 1.4.4)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
@@ -97,6 +104,7 @@ GEM
dotenv (= 2.1.1)
railties (>= 4.0, < 5.1)
erubis (2.7.0)
excon (0.53.0)
execjs (2.7.0)
fabrication (2.15.2)
fast_blank (1.0.0)
@@ -107,7 +115,7 @@ GEM
ruby-progressbar (~> 1.4)
globalid (0.3.7)
activesupport (>= 4.1.0)
goldfinger (1.0.5)
goldfinger (1.1.0)
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
@@ -145,9 +153,10 @@ GEM
json (1.8.3)
launchy (2.4.3)
addressable (~> 2.3)
ledermann-rails-settings (2.4.2)
activerecord (>= 3.1)
letter_opener (1.4.1)
launchy (~> 2.2)
libv8 (3.16.14.15)
link_header (0.0.8)
lograge (0.4.1)
actionpack (>= 4, < 5.1)
@@ -163,15 +172,22 @@ GEM
mime-types-data (3.2016.0521)
mimemagic (0.3.0)
mini_portile2 (2.1.0)
minitest (5.9.0)
minitest (5.9.1)
multi_json (1.12.1)
neography (1.8.0)
excon (>= 0.33.0)
json (>= 1.7.7)
multi_json (>= 1.3.2)
os (>= 0.9.6)
rake (>= 0.8.7)
rubyzip (>= 1.0.0)
nio4r (1.2.1)
nokogiri (1.6.8)
nokogiri (1.6.8.1)
mini_portile2 (~> 2.1.0)
pkg-config (~> 1.1.7)
oj (2.17.3)
orm_adapter (0.5.0)
ostatus2 (0.3.1)
os (0.9.6)
ostatus2 (1.0.2)
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
@@ -187,7 +203,8 @@ GEM
parser (2.3.1.2)
ast (~> 2.2)
pg (0.18.4)
pkg-config (1.1.7)
pghero (1.6.2)
activerecord
powerpack (0.1.1)
pry (0.10.4)
coderay (~> 1.1.0)
@@ -196,11 +213,12 @@ GEM
pry-rails (0.3.4)
pry (>= 0.9.10)
puma (3.6.0)
rabl (0.13.0)
rabl (0.13.1)
activesupport (>= 2.3.14)
rack (2.0.1)
rack-attack (5.0.1)
rack
rack-cors (0.4.0)
rack-protection (1.5.3)
rack
rack-test (0.6.3)
@@ -236,7 +254,7 @@ GEM
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
rainbow (2.1.0)
rake (11.2.2)
rake (11.3.0)
rdoc (4.2.2)
json (~> 1.4)
react-rails (1.8.2)
@@ -247,7 +265,22 @@ GEM
railties (>= 3.2)
tilt
redis (3.3.1)
ref (2.0.0)
redis-actionpack (5.0.0)
actionpack (>= 4.0.0, < 6)
redis-rack (~> 2.0.0.pre)
redis-store (~> 1.2.0.pre)
redis-activesupport (5.0.1)
activesupport (>= 3, < 6)
redis-store (~> 1.2.0)
redis-rack (2.0.0)
rack (~> 2.0)
redis-store (~> 1.2.0)
redis-rails (5.0.1)
redis-actionpack (~> 5.0.0)
redis-activesupport (~> 5.0.0)
redis-store (~> 1.2.0)
redis-store (1.2.0)
redis (>= 2.2)
responders (2.3.0)
railties (>= 4.2.0, < 5.1)
rspec (3.5.0)
@@ -281,6 +314,7 @@ GEM
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.8.1)
rubyzip (1.2.0)
safe_yaml (1.0.4)
sass (3.4.22)
sass-rails (5.0.6)
@@ -314,9 +348,6 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
temple (0.7.7)
therubyracer (0.12.2)
libv8 (~> 3.16.14.0)
ref
thor (0.19.1)
thread_safe (0.3.5)
tilt (2.0.5)
@@ -346,6 +377,8 @@ PLATFORMS
DEPENDENCIES
active_record_query_trace
addressable
autoprefixer-rails
aws-sdk (< 2.0)
better_errors
binding_of_caller
browserify-rails
@@ -366,24 +399,29 @@ DEPENDENCIES
httplog
jbuilder (~> 2.0)
jquery-rails
ledermann-rails-settings
letter_opener
link_header
lograge
neography
nokogiri
oj
ostatus2
paperclip (~> 4.3)
paperclip-av-transcoder
pg
pghero
pry-rails
puma
rabl
rack-attack
rack-cors
rails (= 5.0.0.1)
rails_12factor
rails_autolink
react-rails
redis (~> 3.2)
redis-rails
rspec-rails
rspec-sidekiq
rubocop
@@ -392,7 +430,6 @@ DEPENDENCIES
sidekiq
simple_form
simplecov
therubyracer
uglifier (>= 1.3.0)
webmock
will_paginate

View File

@@ -7,11 +7,13 @@ Mastodon
[travis]: https://travis-ci.org/Gargron/mastodon
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
Click on the screenshot to watch a demo of the UI:
[![Screenshot](https://i.imgur.com/hzWZdYT.png)][youtube_demo]
[![Screenshot](https://i.imgur.com/pNieDFp.png)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
@@ -21,12 +23,13 @@ If you would like, you can [support the development of this project on Patreon][
[patreon]: https://www.patreon.com/user?u=619786
**Current status of the project is early development**
## Resources
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
## Features
@@ -57,6 +60,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
- PostgreSQL
- Redis
- Neo4J (optional)
- GraphAware NodeRank
## Running with Docker and Docker-Compose
@@ -86,6 +91,7 @@ The container has two volumes, for the assets and for user uploads. The default
- `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
- `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
Running any of these tasks via docker-compose would look like this:
@@ -110,3 +116,10 @@ Which will re-create the updated containers, leaving databases and data as is. D
### Contributing
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
### Extra credits
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
![Mastodon error image](https://mastodon.social/oops.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 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,5 @@
import api from '../api'
import axios from 'axios';
import api from '../api'
import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
@@ -31,6 +31,18 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
export function setAccountSelf(account) {
return {
type: ACCOUNT_SET_SELF,
@@ -40,24 +52,32 @@ export function setAccountSelf(account) {
export function fetchAccount(id) {
return (dispatch, getState) => {
const boundApi = api(getState);
dispatch(fetchAccountRequest(id));
axios.all([boundApi.get(`/api/v1/accounts/${id}`), boundApi.get(`/api/v1/accounts/relationships?id=${id}`)]).then(values => {
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0]));
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(fetchAccountSuccess(response.data));
dispatch(fetchRelationships([id]));
}).catch(error => {
dispatch(fetchAccountFail(id, error));
});
};
};
export function fetchAccountTimeline(id) {
export function fetchAccountTimeline(id, replace = false) {
return (dispatch, getState) => {
dispatch(fetchAccountTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data));
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
}
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
}).catch(error => {
dispatch(fetchAccountTimelineFail(id, error));
});
@@ -66,7 +86,7 @@ export function fetchAccountTimeline(id) {
export function expandAccountTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_timelines', id]).last();
const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
dispatch(expandAccountTimelineRequest(id));
@@ -85,11 +105,10 @@ export function fetchAccountRequest(id) {
};
};
export function fetchAccountSuccess(account, relationship) {
export function fetchAccountSuccess(account) {
return {
type: ACCOUNT_FETCH_SUCCESS,
account: account,
relationship: relationship
account: account
};
};
@@ -174,11 +193,12 @@ export function fetchAccountTimelineRequest(id) {
};
};
export function fetchAccountTimelineSuccess(id, statuses) {
export function fetchAccountTimelineSuccess(id, statuses, replace) {
return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id: id,
statuses: statuses
statuses: statuses,
replace: replace
};
};
@@ -278,3 +298,108 @@ export function unblockAccountFail(error) {
error: error
};
};
export function fetchFollowers(id) {
return (dispatch, getState) => {
dispatch(fetchFollowersRequest(id));
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
dispatch(fetchFollowersSuccess(id, response.data));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFollowersFail(id, error));
});
};
};
export function fetchFollowersRequest(id) {
return {
type: FOLLOWERS_FETCH_REQUEST,
id: id
};
};
export function fetchFollowersSuccess(id, accounts) {
return {
type: FOLLOWERS_FETCH_SUCCESS,
id: id,
accounts: accounts
};
};
export function fetchFollowersFail(id, error) {
return {
type: FOLLOWERS_FETCH_FAIL,
id: id,
error: error
};
};
export function fetchFollowing(id) {
return (dispatch, getState) => {
dispatch(fetchFollowingRequest(id));
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
dispatch(fetchFollowingSuccess(id, response.data));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFollowingFail(id, error));
});
};
};
export function fetchFollowingRequest(id) {
return {
type: FOLLOWING_FETCH_REQUEST,
id: id
};
};
export function fetchFollowingSuccess(id, accounts) {
return {
type: FOLLOWING_FETCH_SUCCESS,
id: id,
accounts: accounts
};
};
export function fetchFollowingFail(id, error) {
return {
type: FOLLOWING_FETCH_FAIL,
id: id,
error: error
};
};
export function fetchRelationships(account_ids) {
return (dispatch, getState) => {
dispatch(fetchRelationshipsRequest(account_ids));
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess(response.data));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
};
};
export function fetchRelationshipsRequest(ids) {
return {
type: RELATIONSHIPS_FETCH_REQUEST,
ids: ids
};
};
export function fetchRelationshipsSuccess(relationships) {
return {
type: RELATIONSHIPS_FETCH_SUCCESS,
relationships: relationships
};
};
export function fetchRelationshipsFail(error) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
error: error
};
};

View File

@@ -1,17 +1,23 @@
import api from '../api'
import { updateTimeline } from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@@ -32,6 +38,13 @@ export function cancelReplyCompose() {
};
};
export function mentionCompose(account) {
return {
type: COMPOSE_MENTION,
account: account
};
};
export function submitCompose() {
return function (dispatch, getState) {
dispatch(submitComposeRequest());
@@ -42,6 +55,7 @@ export function submitCompose() {
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
}).then(function (response) {
dispatch(submitComposeSuccess(response.data));
dispatch(updateTimeline('home', response.data));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@@ -121,3 +135,27 @@ export function undoUploadCompose(media_id) {
media_id: media_id
};
};
export function clearComposeSuggestions() {
return {
type: COMPOSE_SUGGESTIONS_CLEAR
};
};
export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({
label: item.get('acct'),
completion: item.get('acct').slice(token.length)
})).toList().toJS();
dispatch(readyComposeSuggestions(loadedCandidates));
};
};
export function readyComposeSuggestions(accounts) {
return {
type: COMPOSE_SUGGESTIONS_READY,
accounts
};
};

View File

@@ -16,6 +16,14 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export function reblog(status) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@@ -157,3 +165,71 @@ export function unfavouriteFail(status, error) {
error: error
};
};
export function fetchReblogs(id) {
return (dispatch, getState) => {
dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
dispatch(fetchReblogsSuccess(id, response.data));
}).catch(error => {
dispatch(fetchReblogsFail(id, error));
});
};
};
export function fetchReblogsRequest(id) {
return {
type: REBLOGS_FETCH_REQUEST,
id
};
};
export function fetchReblogsSuccess(id, accounts) {
return {
type: REBLOGS_FETCH_SUCCESS,
id,
accounts
};
};
export function fetchReblogsFail(id, error) {
return {
type: REBLOGS_FETCH_FAIL,
error
};
};
export function fetchFavourites(id) {
return (dispatch, getState) => {
dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
dispatch(fetchFavouritesSuccess(id, response.data));
}).catch(error => {
dispatch(fetchFavouritesFail(id, error));
});
};
};
export function fetchFavouritesRequest(id) {
return {
type: FAVOURITES_FETCH_REQUEST,
id
};
};
export function fetchFavouritesSuccess(id, accounts) {
return {
type: FAVOURITES_FETCH_SUCCESS,
id,
accounts
};
};
export function fetchFavouritesFail(id, error) {
return {
type: FAVOURITES_FETCH_FAIL,
error
};
};

View File

@@ -0,0 +1,15 @@
export const MEDIA_OPEN = 'MEDIA_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';
export function openMedia(url) {
return {
type: MEDIA_OPEN,
url: url
};
};
export function closeModal() {
return {
type: MODAL_CLOSE
};
};

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

@@ -1,5 +1,6 @@
import api from '../api';
import axios from 'axios';
import api from '../api';
import { deleteFromTimelines } from './timelines';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -9,6 +10,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
export function fetchStatusRequest(id) {
return {
type: STATUS_FETCH_REQUEST,
@@ -18,12 +23,11 @@ export function fetchStatusRequest(id) {
export function fetchStatus(id) {
return (dispatch, getState) => {
const boundApi = api(getState);
dispatch(fetchStatusRequest(id));
axios.all([boundApi.get(`/api/v1/statuses/${id}`), boundApi.get(`/api/v1/statuses/${id}/context`)]).then(values => {
dispatch(fetchStatusSuccess(values[0].data, values[1].data));
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
dispatch(fetchStatusSuccess(response.data));
dispatch(fetchContext(id));
}).catch(error => {
dispatch(fetchStatusFail(id, error));
});
@@ -52,6 +56,7 @@ export function deleteStatus(id) {
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
}).catch(error => {
dispatch(deleteStatusFail(id, error));
});
@@ -79,3 +84,40 @@ export function deleteStatusFail(id, error) {
error: error
};
};
export function fetchContext(id) {
return (dispatch, getState) => {
dispatch(fetchContextRequest(id));
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
}).catch(error => {
dispatch(fetchContextFail(id, error));
});
};
};
export function fetchContextRequest(id) {
return {
type: CONTEXT_FETCH_REQUEST,
id
};
};
export function fetchContextSuccess(id, ancestors, descendants) {
return {
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors,
descendants,
statuses: ancestors.concat(descendants)
};
};
export function fetchContextFail(id, error) {
return {
type: CONTEXT_FETCH_FAIL,
id,
error
};
};

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

View File

@@ -1,4 +1,5 @@
import api from '../api'
import Immutable from 'immutable';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -11,26 +12,39 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export function refreshTimelineSuccess(timeline, statuses) {
export function refreshTimelineSuccess(timeline, statuses, replace) {
return {
type: TIMELINE_REFRESH_SUCCESS,
timeline: timeline,
statuses: statuses
statuses: statuses,
replace: replace
};
};
export function updateTimeline(timeline, status) {
return {
type: TIMELINE_UPDATE,
timeline: timeline,
status: status
return (dispatch, getState) => {
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
dispatch({
type: TIMELINE_UPDATE,
timeline,
status,
references
});
};
};
export function deleteFromTimelines(id) {
return {
type: TIMELINE_DELETE,
id: id
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
dispatch({
type: TIMELINE_DELETE,
id,
accountId,
references
});
};
};
@@ -41,12 +55,26 @@ export function refreshTimelineRequest(timeline) {
};
};
export function refreshTimeline(timeline) {
export function refreshTimeline(timeline, replace = false, id = null) {
return function (dispatch, getState) {
dispatch(refreshTimelineRequest(timeline));
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data));
const ids = getState().getIn(['timelines', timeline], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let path = timeline;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
}
if (id) {
path = `${path}/${id}`
}
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data, replace));
}).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error));
});
@@ -61,13 +89,19 @@ export function refreshTimelineFail(timeline, error) {
};
};
export function expandTimeline(timeline) {
export function expandTimeline(timeline, id = null) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', timeline]).last();
const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last();
dispatch(expandTimelineRequest(timeline));
api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => {
let path = timeline;
if (id) {
path = `${path}/${id}`
}
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
dispatch(expandTimelineSuccess(timeline, response.data));
}).catch(error => {
dispatch(expandTimelineFail(timeline, error));

View File

@@ -0,0 +1,40 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
const outerStyle = {
padding: '15px',
fontSize: '16px',
background: '#2f3441',
flex: '0 0 auto',
cursor: 'pointer',
color: '#2b90d9'
};
const iconStyle = {
display: 'inline-block',
marginRight: '5px'
};
const ColumnBackButton = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
mixins: [PureRenderMixin],
handleClick () {
this.context.router.goBack();
},
render () {
return (
<div onClick={this.handleClick} style={outerStyle}>
<i className='fa fa-fw fa-chevron-left' style={iconStyle} />
Back
</div>
);
}
});
export default ColumnBackButton;

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

@@ -28,16 +28,21 @@ const IconButton = React.createClass({
render () {
const style = {
display: 'inline-block',
border: 'none',
padding: '0',
background: 'transparent',
fontSize: `${this.props.size}px`,
width: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`
lineHeight: `${this.props.size}px`,
cursor: 'pointer',
...this.props.style
};
return (
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`}></i>
</a>
<button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
);
}

View File

@@ -0,0 +1,53 @@
import IconButton from './icon_button';
const overlayStyle = {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
justifyContent: 'center',
alignContent: 'center',
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
zIndex: '9999'
};
const dialogStyle = {
color: '#282c37',
background: '#d9e1e8',
borderRadius: '4px',
boxShadow: '0 0 15px rgba(0, 0, 0, 0.4)',
padding: '10px',
margin: 'auto',
position: 'relative'
};
const closeStyle = {
position: 'absolute',
top: '4px',
right: '4px'
};
const Lightbox = ({ isVisible, onOverlayClicked, onCloseClicked, children }) => {
if (!isVisible) {
return <div />;
}
return (
<div className='lightbox' style={overlayStyle} onClick={onOverlayClicked}>
<div style={dialogStyle}>
<IconButton title='Close' icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
{children}
</div>
</div>
);
};
Lightbox.propTypes = {
isVisible: React.PropTypes.bool,
onOverlayClicked: React.PropTypes.func,
onCloseClicked: React.PropTypes.func
};
export default Lightbox;

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

@@ -5,11 +5,21 @@ const MediaGallery = React.createClass({
propTypes: {
media: ImmutablePropTypes.list.isRequired,
height: React.PropTypes.number.isRequired
height: React.PropTypes.number.isRequired,
onOpenMedia: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleClick (url, e) {
if (e.button === 0) {
e.preventDefault();
this.props.onOpenMedia(url);
}
e.stopPropagation();
},
render () {
var children = this.props.media.take(4);
var size = children.size;
@@ -25,7 +35,7 @@ const MediaGallery = React.createClass({
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}
@@ -64,7 +74,11 @@ const MediaGallery = React.createClass({
}
}
return <a key={attachment.get('id')} href={attachment.get('url')} target='_blank' style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', textDecoration: 'none', border: 'none', display: 'block', width: `${width}%`, height: `${height}%`, background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover', cursor: 'zoom-in' }} />;
return (
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
<a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
</div>
);
});
return (

View File

@@ -21,35 +21,28 @@ moment.updateLocale('en', {
const RelativeTimestamp = React.createClass({
getInitialState () {
return {
text: ''
};
},
propTypes: {
timestamp: React.PropTypes.string.isRequired
timestamp: React.PropTypes.string.isRequired,
now: React.PropTypes.any
},
mixins: [PureRenderMixin],
componentWillMount () {
this._updateMomentText();
this.interval = setInterval(this._updateMomentText, 6000);
},
componentWillUnmount () {
clearInterval(this.interval);
},
_updateMomentText () {
this.setState({ text: moment(this.props.timestamp).fromNow() });
},
render () {
const timestamp = moment(this.props.timestamp);
const now = this.props.now;
let string = '';
if (timestamp.isAfter(now)) {
string = 'Just now';
} else {
string = timestamp.from(now);
}
return (
<span>
{this.state.text}
{string}
</span>
);
}

View File

@@ -15,13 +15,15 @@ const Status = React.createClass({
},
propTypes: {
status: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
wrapped: React.PropTypes.bool,
onReply: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func,
me: React.PropTypes.number
onOpenMedia: React.PropTypes.func,
me: React.PropTypes.number,
now: React.PropTypes.any
},
mixins: [PureRenderMixin],
@@ -41,16 +43,25 @@ const Status = React.createClass({
},
render () {
var media = '';
let media = '';
const { status, now, ...other } = this.props;
var { status, ...other } = this.props;
if (status === null) {
return <div />;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
let displayName = status.getIn(['account', 'display_name']);
if (displayName.length === 0) {
displayName = status.getIn(['account', 'username']);
}
if (status.get('reblog') !== null) {
return (
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}>
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div>
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{status.getIn(['account', 'display_name'])}</strong></a> reblogged
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{displayName}</strong></a> reblogged
</div>
<Status {...other} wrapped={true} status={status.get('reblog')} />
@@ -62,7 +73,7 @@ const Status = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
} else {
media = <MediaGallery media={status.get('media_attachments')} height={110} />;
media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />;
}
}
@@ -70,7 +81,7 @@ const Status = React.createClass({
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
<div style={{ fontSize: '15px' }}>
<div style={{ float: 'right', fontSize: '14px' }}>
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} now={now} /></a>
</div>
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>

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: {
@@ -9,7 +9,8 @@ const StatusActionBar = React.createClass({
onReply: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func
onDelete: React.PropTypes.func,
onMention: React.PropTypes.func
},
mixins: [PureRenderMixin],
@@ -26,23 +27,22 @@ const StatusActionBar = React.createClass({
this.props.onReblog(this.props.status);
},
handleDeleteClick(e) {
e.preventDefault();
handleDeleteClick () {
this.props.onDelete(this.props.status);
},
handleMentionClick () {
this.props.onMention(this.props.status.get('account'));
},
render () {
const { status, me } = this.props;
let menu = '';
let menu = [];
if (status.getIn(['account', 'id']) === me) {
menu = (
<ul>
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
</ul>
);
menu.push({ text: 'Delete', action: this.handleDeleteClick });
} else {
menu = <ul />;
menu.push({ text: 'Mention', action: this.handleMentionClick });
}
return (
@@ -52,13 +52,7 @@ const StatusActionBar = React.createClass({
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
<Dropdown>
<DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}>
<i className='fa fa-fw fa-ellipsis-h' />
</DropdownTrigger>
<DropdownContent>{menu}</DropdownContent>
</Dropdown>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
</div>
</div>
);

View File

@@ -1,5 +1,10 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import emojione from 'emojione';
emojione.imageType = 'png';
emojione.sprites = false;
emojione.imagePathPNG = '/emoji/';
const StatusContent = React.createClass({
@@ -22,12 +27,15 @@ const StatusContent = React.createClass({
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention));
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
link.addEventListener('click', this.onNormalClick.bind(this));
}
link.addEventListener('click', this.onNormalClick, false);
}
},
@@ -36,8 +44,15 @@ const StatusContent = React.createClass({
e.preventDefault();
this.context.router.push(`/accounts/${mention.get('id')}`);
}
},
e.stopPropagation();
onHashtagClick (hashtag, e) {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/statuses/tag/${hashtag}`);
}
},
onNormalClick (e) {
@@ -45,7 +60,7 @@ const StatusContent = React.createClass({
},
render () {
const content = { __html: this.props.status.get('content') };
const content = { __html: emojione.unicodeToImage(this.props.status.get('content')) };
return <div className='status__content' dangerouslySetInnerHTML={content} />;
},

View File

@@ -1,41 +1,82 @@
import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { ScrollContainer } from 'react-router-scroll';
import StatusContainer from '../containers/status_container';
import moment from 'moment';
const StatusList = React.createClass({
propTypes: {
statuses: ImmutablePropTypes.list.isRequired,
onReply: React.PropTypes.func,
onReblog: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onDelete: React.PropTypes.func,
statusIds: ImmutablePropTypes.list.isRequired,
onScrollToBottom: React.PropTypes.func,
me: React.PropTypes.number
trackScroll: React.PropTypes.bool
},
getDefaultProps () {
return {
trackScroll: true
};
},
getInitialState () {
return {
now: moment()
};
},
mixins: [PureRenderMixin],
componentDidMount () {
this._interval = setInterval(() => this.setState({ now: moment() }), 60000);
},
componentWillUnmount () {
clearInterval(this._interval);
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight) {
this.props.onScrollToBottom();
}
},
render () {
const { statuses, onScrollToBottom, ...other } = this.props;
componentDidUpdate (prevProps) {
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
const node = ReactDOM.findDOMNode(this);
return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
if (node.scrollTop > 0) {
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
}
}
},
render () {
const { statusIds, onScrollToBottom, trackScroll } = this.props;
const scrollableArea = (
<div className='scrollable' onScroll={this.handleScroll}>
<div>
{statuses.map((status) => {
return <Status key={status.get('id')} {...other} status={status} />;
{statusIds.map((statusId) => {
return <StatusContainer key={statusId} id={statusId} now={this.state.now} />;
})}
</div>
</div>
);
if (trackScroll) {
return (
<ScrollContainer scrollKey='status-list'>
{scrollableArea}
</ScrollContainer>
);
} else {
return scrollableArea;
}
}
});

View File

@@ -2,6 +2,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
const videoStyle = {
position: 'relative',
zIndex: '1',
width: '100%',
height: '100%',
objectFit: 'cover',
top: '50%',
transform: 'translateY(-50%)'
};
const muteStyle = {
position: 'absolute',
top: '10px',
left: '10px',
opacity: '0.8',
zIndex: '5'
};
const VideoPlayer = React.createClass({
propTypes: {
media: ImmutablePropTypes.map.isRequired,
@@ -28,11 +46,23 @@ const VideoPlayer = React.createClass({
this.setState({ muted: !this.state.muted });
},
handleVideoClick (e) {
e.stopPropagation();
const node = ReactDOM.findDOMNode(this).querySelector('video');
if (node.paused) {
node.play();
} else {
node.pause();
}
},
render () {
return (
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
<div style={{ position: 'absolute', top: '10px', left: '10px', opacity: '0.8' }}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
<div style={muteStyle}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div>
);
}

View File

@@ -1,20 +1,36 @@
import { Provider } from 'react-redux';
import configureStore from '../store/configureStore';
import { Provider } from 'react-redux';
import configureStore from '../store/configureStore';
import {
refreshTimelineSuccess,
updateTimeline,
deleteFromTimelines,
refreshTimeline
} from '../actions/timelines';
import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Router, Route, hashHistory } from 'react-router';
import Account from '../features/account';
import Settings from '../features/settings';
import Status from '../features/status';
import Subscriptions from '../features/subscriptions';
import UI from '../features/ui';
} from '../actions/timelines';
import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {
applyRouterMiddleware,
Router,
Route,
hashHistory,
IndexRoute
} from 'react-router';
import { useScroll } from 'react-router-scroll';
import UI from '../features/ui';
import Account from '../features/account';
import Status from '../features/status';
import GettingStarted from '../features/getting_started';
import PublicTimeline from '../features/public_timeline';
import AccountTimeline from '../features/account_timeline';
import HomeTimeline from '../features/home_timeline';
import MentionsTimeline from '../features/mentions_timeline';
import Compose from '../features/compose';
import Followers from '../features/followers';
import Following from '../features/following';
import Reblogs from '../features/reblogs';
import Favourites from '../features/favourites';
import HashtagTimeline from '../features/hashtag_timeline';
const store = configureStore();
@@ -32,21 +48,8 @@ const Mastodon = React.createClass({
store.dispatch(setAccessToken(this.props.token));
store.dispatch(setAccountSelf(JSON.parse(this.props.account)));
for (var timelineType in this.props.timelines) {
if (this.props.timelines.hasOwnProperty(timelineType)) {
store.dispatch(refreshTimelineSuccess(timelineType, JSON.parse(this.props.timelines[timelineType])));
}
}
if (typeof App !== 'undefined') {
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
connected () {
},
disconnected () {
},
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
received (data) {
switch(data.type) {
@@ -56,24 +59,44 @@ const Mastodon = React.createClass({
return store.dispatch(deleteFromTimelines(data.id));
case 'merge':
case 'unmerge':
return store.dispatch(refreshTimeline('home'));
return store.dispatch(refreshTimeline('home', true));
case 'block':
return store.dispatch(refreshTimeline('mentions'));
return store.dispatch(refreshTimeline('mentions', true));
}
}
});
}
},
componentWillUnmount () {
if (typeof this.subscription !== 'undefined') {
this.subscription.unsubscribe();
}
},
render () {
return (
<Provider store={store}>
<Router history={hashHistory}>
<Router history={hashHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={UI}>
<Route path='/settings' component={Settings} />
<Route path='/subscriptions' component={Subscriptions} />
<IndexRoute component={GettingStarted} />
<Route path='/statuses/new' component={Compose} />
<Route path='/statuses/home' component={HomeTimeline} />
<Route path='/statuses/mentions' component={MentionsTimeline} />
<Route path='/statuses/all' component={PublicTimeline} />
<Route path='/statuses/tag/:id' component={HashtagTimeline} />
<Route path='/statuses/:statusId' component={Status} />
<Route path='/accounts/:accountId' component={Account} />
<Route path='/statuses/:statusId/reblogs' component={Reblogs} />
<Route path='/statuses/:statusId/favourites' component={Favourites} />
<Route path='/accounts/:accountId' component={Account}>
<IndexRoute component={AccountTimeline} />
<Route path='/accounts/:accountId/followers' component={Followers} />
<Route path='/accounts/:accountId/following' component={Following} />
</Route>
</Route>
</Router>
</Provider>

View File

@@ -0,0 +1,102 @@
import { connect } from 'react-redux';
import Status from '../components/status';
import { makeGetStatus } from '../selectors';
import {
replyCompose,
mentionCompose
} from '../actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite
} from '../actions/interactions';
import { deleteStatus } from '../actions/statuses';
import { openMedia } from '../actions/modal';
import { createSelector } from 'reselect'
const mapStateToProps = (state, props) => ({
statusBase: state.getIn(['statuses', props.id]),
me: state.getIn(['meta', 'me'])
});
const makeMapStateToPropsInner = () => {
const getStatus = (() => {
return createSelector(
[
(_, base) => base,
(state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
(state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
],
(base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
);
})();
const mapStateToProps = (state, { statusBase }) => ({
status: getStatus(state, statusBase)
});
return mapStateToProps;
};
const makeMapStateToPropsLast = () => {
const getStatus = (() => {
return createSelector(
[
(_, status) => status,
(state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
],
(status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
);
})();
const mapStateToProps = (state, { status }) => ({
status: getStatus(state, status)
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onReply (status) {
dispatch(replyCompose(status));
},
onReblog (status) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
}
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
},
onMention (account) {
dispatch(mentionCompose(account));
},
onOpenMedia (url) {
dispatch(openMedia(url));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(
connect(makeMapStateToPropsInner)(
connect(makeMapStateToPropsLast)(Status)
)
);

View File

@@ -1,6 +1,27 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Button from '../../../components/button';
import DropdownMenu from '../../../components/dropdown_menu';
import { Link } from 'react-router';
const outerStyle = {
borderTop: '1px solid #363c4b',
borderBottom: '1px solid #363c4b',
lineHeight: '36px',
overflow: 'hidden',
flex: '0 0 auto',
display: 'flex'
};
const outerDropdownStyle = {
padding: '10px',
flex: '1 1 auto'
};
const outerLinksStyle = {
flex: '1 1 auto',
display: 'flex',
lineHeight: '18px'
};
const ActionBar = React.createClass({
@@ -8,7 +29,8 @@ const ActionBar = React.createClass({
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@@ -16,47 +38,44 @@ const ActionBar = React.createClass({
render () {
const { account, me } = this.props;
let infoText = '';
let follow = '';
let buttonText = '';
let block = '';
let disabled = false;
let menu = [];
menu.push({ text: 'Mention', action: this.props.onMention });
if (account.get('id') === me) {
buttonText = 'This is you!';
disabled = true;
menu.push({ text: 'Edit profile', href: '/settings/profile' });
} else if (account.getIn(['relationship', 'blocking'])) {
menu.push({ text: 'Unblock', action: this.props.onBlock });
} else if (account.getIn(['relationship', 'following'])) {
menu.push({ text: 'Unfollow', action: this.props.onFollow });
menu.push({ text: 'Block', action: this.props.onBlock });
} else {
let blockText = '';
if (account.getIn(['relationship', 'blocking'])) {
buttonText = 'Blocked';
disabled = true;
blockText = 'Unblock';
} else {
if (account.getIn(['relationship', 'following'])) {
buttonText = 'Unfollow';
} else {
buttonText = 'Follow';
}
if (account.getIn(['relationship', 'followed_by'])) {
infoText = 'Follows you!';
}
blockText = 'Block';
}
block = <Button text={blockText} onClick={this.props.onBlock} />;
}
if (!account.getIn(['relationship', 'blocking'])) {
follow = <Button text={buttonText} onClick={this.props.onFollow} disabled={disabled} />;
menu.push({ text: 'Follow', action: this.props.onFollow });
menu.push({ text: 'Block', action: this.props.onBlock });
}
return (
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}>
{follow} {block}
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span>
<div style={outerStyle}>
<div style={outerDropdownStyle}>
<DropdownMenu items={menu} icon='bars' size={24} />
</div>
<div style={outerLinksStyle}>
<Link to={`/accounts/${account.get('id')}`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
</Link>
<Link to={`/accounts/${account.get('id')}/following`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px 5px' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
</Link>
<Link to={`/accounts/${account.get('id')}/followers`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', padding: '10px 5px', borderLeft: '1px solid #363c4b' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
</Link>
</div>
</div>
);
},

View File

@@ -4,24 +4,43 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const Header = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
render () {
const { account } = this.props;
const { account, me } = this.props;
let displayName = account.get('display_name');
let info = '';
if (displayName.length === 0) {
displayName = account.get('username');
}
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
}
const content = { __html: account.get('note') };
return (
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover' }}>
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div>
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '20px 10px' }}>
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
<div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div>
<span style={{ color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500', display: 'block' }}>{account.get('display_name')}</span>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
</a>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')}</span>
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
</div>
</div>
);

View File

@@ -10,53 +10,46 @@ import {
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
import { deleteStatus } from '../../actions/statuses';
import { replyCompose } from '../../actions/compose';
import {
favourite,
reblog,
unreblog,
unfavourite
} from '../../actions/interactions';
import { mentionCompose } from '../../actions/compose';
import Header from './components/header';
import {
selectStatus,
selectAccount
} from '../../reducers/timelines';
import StatusList from '../../components/status_list';
import Immutable from 'immutable';
getAccountTimeline,
makeGetAccount
} from '../../selectors';
import LoadingIndicator from '../../components/loading_indicator';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
function selectStatuses(state, accountId) {
return state.getIn(['timelines', 'accounts_timelines', accountId], Immutable.List()).map(id => selectStatus(state, id)).filterNot(status => status === null);
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const mapStateToProps = (state, props) => ({
account: selectAccount(state, Number(props.params.accountId)),
statuses: selectStatuses(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
});
const Account = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
statuses: ImmutablePropTypes.list
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
}
},
@@ -76,50 +69,33 @@ const Account = React.createClass({
}
},
handleReply (status) {
this.props.dispatch(replyCompose(status));
},
handleReblog (status) {
if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
this.props.dispatch(reblog(status));
}
},
handleFavourite (status) {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
} else {
this.props.dispatch(favourite(status));
}
},
handleDelete (status) {
this.props.dispatch(deleteStatus(status.get('id')));
},
handleScrollToBottom () {
this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
handleMention () {
this.props.dispatch(mentionCompose(this.props.account));
},
render () {
const { account, statuses, me } = this.props;
const { account, me } = this.props;
if (account === null) {
return <div>Loading {this.props.params.accountId}...</div>;
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
<Header account={account} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
<StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
</div>
<Column>
<ColumnBackButton />
<Header account={account} me={me} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} />
{this.props.children}
</Column>
);
}
});
export default connect(mapStateToProps)(Account);
export default connect(makeMapStateToProps)(Account);

View File

@@ -0,0 +1,52 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
me: state.getIn(['meta', 'me'])
});
const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
}
},
handleScrollToBottom () {
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
},
render () {
const { statusIds, me } = this.props;
if (!statusIds) {
return <LoadingIndicator />;
}
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
}
});
export default connect(mapStateToProps)(AccountTimeline);

View File

@@ -0,0 +1,85 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import AccountContainer from '../../followers/containers/account_container';
const outerStyle = {
position: 'relative'
};
const headerStyle = {
fontSize: '14px',
fontWeight: '500',
display: 'block',
padding: '10px',
color: '#9baec8',
background: '#454b5e',
overflow: 'hidden'
};
const nextStyle = {
display: 'inline-block',
float: 'right',
fontWeight: '400',
color: '#2b90d9'
};
const SuggestionsBox = React.createClass({
propTypes: {
accountIds: ImmutablePropTypes.list,
perWindow: React.PropTypes.number
},
getInitialState () {
return {
index: 0
};
},
getDefaultProps () {
return {
perWindow: 2
};
},
mixins: [PureRenderMixin],
handleNextClick (e) {
e.preventDefault();
let newIndex = this.state.index + 1;
if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) {
newIndex = 0;
}
this.setState({ index: newIndex });
},
render () {
const { accountIds, perWindow } = this.props;
if (!accountIds || accountIds.size === 0) {
return <div />;
}
let nextLink = '';
if (accountIds.size > perWindow) {
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Refresh</a>;
}
return (
<div style={outerStyle}>
<strong style={headerStyle}>
Who to follow {nextLink}
</strong>
{accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
</div>
);
}
});
export default SuggestionsBox;

View File

@@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import SuggestionsBox from '../components/suggestions_box';
const mapStateToProps = (state) => ({
accountIds: state.getIn(['user_lists', 'suggestions'])
});
export default connect(mapStateToProps)(SuggestionsBox);

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,61 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavourites } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
});
const Favourites = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='favourites'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(Favourites);

View File

@@ -0,0 +1,94 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { Link } from 'react-router';
import IconButton from '../../../components/icon_button';
const outerStyle = {
padding: '10px',
borderBottom: '1px solid #363c4b'
};
const itemStyle = {
flex: '1 1 auto',
display: 'block',
color: '#9baec8',
overflow: 'hidden',
textDecoration: 'none',
fontSize: '14px'
};
const noteStyle = {
paddingTop: '5px',
fontSize: '12px',
color: '#616b86'
};
const buttonsStyle = {
padding: '10px'
};
const Account = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool
},
getDefaultProps () {
return {
withNote: true
};
},
mixins: [PureRenderMixin],
handleFollow () {
this.props.onFollow(this.props.account);
},
render () {
const { account, me, withNote } = this.props;
if (!account) {
return <div />;
}
let note, buttons;
if (account.get('note').length > 0 && withNote) {
note = <div style={noteStyle}>{account.get('note')}</div>;
}
if (account.get('id') !== me) {
const following = account.getIn(['relationship', 'following']);
buttons = (
<div style={buttonsStyle}>
<IconButton icon={following ? 'user-times' : 'user-plus'} title='Follow' onClick={this.handleFollow} active={following} />
</div>
);
}
return (
<div style={outerStyle}>
<div style={{ display: 'flex' }}>
<Link key={account.get('id')} style={itemStyle} className='account__display-name' to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
<DisplayName account={account} />
</Link>
{buttons}
</div>
{note}
</div>
);
}
});
export default Account;

View File

@@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import Account from '../components/account';
import {
followAccount,
unfollowAccount
} from '../../../actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following'])) {
dispatch(unfollowAccount(account.get('id')));
} else {
dispatch(followAccount(account.get('id')));
}
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Account);

View File

@@ -0,0 +1,51 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFollowers } from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from './containers/account_container';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId)])
});
const Followers = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return <LoadingIndicator />;
}
return (
<ScrollContainer scrollKey='followers'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
);
}
});
export default connect(mapStateToProps)(Followers);

View File

@@ -0,0 +1,51 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFollowing } from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId)])
});
const Following = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return <LoadingIndicator />;
}
return (
<ScrollContainer scrollKey='following'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
);
}
});
export default connect(mapStateToProps)(Following);

View File

@@ -0,0 +1,18 @@
import Column from '../ui/components/column';
import { Link } from 'react-router';
const GettingStarted = () => {
return (
<Column>
<div className='static-content'>
<h1>Getting started</h1>
<p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p>
<p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p>
<p>The developer of this project can be followed as Gargron@mastodon.social</p>
<p>Also <Link to='/statuses/all' style={{ color: '#2b90d9', textDecoration: 'none' }}>check out the public timeline for a start</Link>!</p>
</div>
</Column>
);
};
export default GettingStarted;

View File

@@ -0,0 +1,78 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../ui/components/column';
import {
refreshTimeline,
updateTimeline,
deleteFromTimelines
} from '../../actions/timelines';
const HashtagTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
_subscribe (dispatch, id) {
if (typeof App !== 'undefined') {
this.subscription = App.cable.subscriptions.create({
channel: 'HashtagChannel',
tag: id
}, {
received (data) {
switch(data.type) {
case 'update':
return dispatch(updateTimeline('tag', JSON.parse(data.message)));
case 'delete':
return dispatch(deleteFromTimelines(data.id));
}
}
});
}
},
_unsubscribe () {
if (typeof this.subscription !== 'undefined') {
this.subscription.unsubscribe();
}
},
componentWillMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(refreshTimeline('tag', true, id));
this._subscribe(dispatch, id);
},
componentWillReceiveProps (nextProps) {
if (nextProps.params.id !== this.props.params.id) {
this.props.dispatch(refreshTimeline('tag', true, nextProps.params.id));
this._unsubscribe();
this._subscribe(this.props.dispatch, nextProps.params.id);
}
},
componentWillUnmount () {
this._unsubscribe();
},
render () {
const { id } = this.props.params;
return (
<Column icon='hashtag' heading={id}>
<StatusListContainer type='tag' id={id} />
</Column>
);
},
});
export default connect()(HashtagTimeline);

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

View File

@@ -0,0 +1,56 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../ui/components/column';
import {
refreshTimeline,
updateTimeline,
deleteFromTimelines
} from '../../actions/timelines';
const PublicTimeline = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
const { dispatch } = this.props;
dispatch(refreshTimeline('public'));
if (typeof App !== 'undefined') {
this.subscription = App.cable.subscriptions.create('PublicChannel', {
received (data) {
switch(data.type) {
case 'update':
return dispatch(updateTimeline('public', JSON.parse(data.message)));
case 'delete':
return dispatch(deleteFromTimelines(data.id));
}
}
});
}
},
componentWillUnmount () {
if (typeof this.subscription !== 'undefined') {
this.subscription.unsubscribe();
}
},
render () {
return (
<Column icon='globe' heading='Public'>
<StatusListContainer type='public' />
</Column>
);
},
});
export default connect()(PublicTimeline);

View File

@@ -0,0 +1,61 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchReblogs } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)])
});
const Reblogs = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='reblogs'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(Reblogs);

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,39 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenu from '../../../components/dropdown_menu';
const ActionBar = React.createClass({
propTypes: {
status: ImmutablePropTypes.map.isRequired,
onReply: React.PropTypes.func.isRequired,
onReblog: React.PropTypes.func.isRequired,
onFavourite: React.PropTypes.func.isRequired
onFavourite: React.PropTypes.func.isRequired,
onDelete: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
render () {
const { status } = this.props;
const { status, me } = this.props;
let menu = [];
if (me === status.getIn(['account', 'id'])) {
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
} else {
menu.push({ text: 'Mention', action: () => this.props.onMention(status.get('account')) });
}
return (
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import VideoPlayer from '../../../components/video_player';
import moment from 'moment';
import { Link } from 'react-router';
const DetailedStatus = React.createClass({
@@ -14,7 +15,8 @@ const DetailedStatus = React.createClass({
},
propTypes: {
status: ImmutablePropTypes.map.isRequired
status: ImmutablePropTypes.map.isRequired,
onOpenMedia: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@@ -36,7 +38,7 @@ const DetailedStatus = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
} else {
media = <MediaGallery media={status.get('media_attachments')} height={300} />;
media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
}
}
@@ -52,7 +54,7 @@ const DetailedStatus = React.createClass({
{media}
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span> · <i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span></Link>
</div>
</div>
);

View File

@@ -4,30 +4,47 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses';
import Immutable from 'immutable';
import EmbeddedStatus from '../../components/status';
import LoadingIndicator from '../../components/loading_indicator';
import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose';
import { selectStatus } from '../../reducers/timelines';
import {
replyCompose,
mentionCompose
} from '../../actions/compose';
import { deleteStatus } from '../../actions/statuses';
import {
makeGetStatus,
getStatusAncestors,
getStatusDescendants
} from '../../selectors';
import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container';
import { openMedia } from '../../actions/modal';
function selectStatuses(state, ids) {
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null);
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
status: getStatus(state, Number(props.params.statusId)),
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const mapStateToProps = (state, props) => ({
status: selectStatus(state, Number(props.params.statusId)),
ancestors: selectStatuses(state, state.getIn(['timelines', 'ancestors', Number(props.params.statusId)], Immutable.OrderedSet())),
descendants: selectStatuses(state, state.getIn(['timelines', 'descendants', Number(props.params.statusId)], Immutable.OrderedSet()))
});
const Status = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
ancestors: ImmutablePropTypes.orderedSet.isRequired,
descendants: ImmutablePropTypes.orderedSet.isRequired
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
@@ -54,31 +71,62 @@ const Status = React.createClass({
this.props.dispatch(reblog(status));
},
handleDeleteClick (status) {
this.props.dispatch(deleteStatus(status.get('id')));
},
handleMentionClick (account) {
this.props.dispatch(mentionCompose(account));
},
handleOpenMedia (url) {
this.props.dispatch(openMedia(url));
},
renderChildren (list) {
return list.map(s => <EmbeddedStatus status={s} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />);
return list.map(id => <StatusContainer key={id} id={id} />);
},
render () {
const { status, ancestors, descendants } = this.props;
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, me } = this.props;
if (status === null) {
return <div>Loading {this.props.params.statusId}...</div>;
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const account = status.get('account');
if (ancestorsIds && ancestorsIds.size > 0) {
ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
}
if (descendantsIds && descendantsIds.size > 0) {
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
}
return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
<div>{this.renderChildren(ancestors)}</div>
<Column>
<ColumnBackButton />
<DetailedStatus status={status} />
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
<ScrollContainer scrollKey='thread'>
<div className='scrollable'>
{ancestors}
<div>{this.renderChildren(descendants)}</div>
</div>
<DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} />
{descendants}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(Status);
export default connect(makeMapStateToProps)(Status);

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

@@ -4,11 +4,62 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ReplyIndicator from './reply_indicator';
import UploadButton from './upload_button';
import Autosuggest from 'react-autosuggest';
const getTokenForSuggestions = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 2 || word[0] !== '@') {
return null;
}
word = word.trim().toLowerCase().slice(1);
if (word.length > 0) {
return word;
} else {
return null;
}
};
const getSuggestionValue = suggestion => suggestion.completion;
const renderSuggestion = suggestion => (
<span>{suggestion.label}</span>
);
const textareaStyle = {
display: 'block',
boxSizing: 'border-box',
width: '100%',
height: '100px',
resize: 'none',
border: 'none',
color: '#282c37',
padding: '10px',
fontFamily: 'Roboto',
fontSize: '14px',
margin: '0'
};
const renderInputComponent = inputProps => (
<textarea {...inputProps} placeholder='What is on your mind?' className='compose-form__textarea' style={textareaStyle} />
);
const ComposeForm = React.createClass({
propTypes: {
text: React.PropTypes.string.isRequired,
suggestions: React.PropTypes.array,
is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({
componentDidUpdate (prevProps) {
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
this.refs.textarea.focus();
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
textarea.focus();
}
}
},
onSuggestionsClearRequested () {
this.props.onClearSuggestions();
},
onSuggestionsFetchRequested ({ value }) {
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
const token = getTokenForSuggestions(value, textarea.selectionStart);
if (token !== null) {
this.props.onFetchSuggestions(token);
}
}
},
onSuggestionSelected (e, { suggestionValue, method }) {
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
const str = this.props.text;
this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join(''));
}
},
@@ -47,11 +130,29 @@ const ComposeForm = React.createClass({
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
}
const inputProps = {
placeholder: 'What is on your mind?',
value: this.props.text,
onKeyUp: this.handleKeyUp,
onChange: this.handleChange,
disabled: disabled
};
return (
<div style={{ padding: '10px' }}>
{replyArea}
<textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} />
<Autosuggest
ref='autosuggest'
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
renderInputComponent={renderInputComponent}
inputProps={inputProps}
/>
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>

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

@@ -24,7 +24,7 @@ const UploadButton = React.createClass({
return (
<div>
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
<i className='fa fa-fw fa-photo' /> Add images
<i className='fa fa-fw fa-photo' /> Add media
</Button>
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />

View File

@@ -1,31 +1,52 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
import { selectStatus } from '../../../reducers/timelines';
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import {
changeCompose,
submitCompose,
cancelReplyCompose,
clearComposeSuggestions,
fetchComposeSuggestions
} from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
};
};
return mapStateToProps;
};
const mapDispatchToProps = function (dispatch) {
return {
onChange: function (text) {
onChange (text) {
dispatch(changeCompose(text));
},
onSubmit: function () {
onSubmit () {
dispatch(submitCompose());
},
onCancelReply: function () {
onCancelReply () {
dispatch(cancelReplyCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
}
}
};
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);

View File

@@ -0,0 +1,48 @@
import { connect } from 'react-redux';
import { closeModal } from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
const mapStateToProps = state => ({
url: state.getIn(['modal', 'url']),
isVisible: state.getIn(['modal', 'open'])
});
const mapDispatchToProps = dispatch => ({
onCloseClicked () {
dispatch(closeModal());
},
onOverlayClicked () {
dispatch(closeModal());
}
});
const imageStyle = {
display: 'block',
maxWidth: '100%',
height: 'auto',
margin: '0 auto'
};
const Modal = React.createClass({
propTypes: {
url: React.PropTypes.string,
isVisible: React.PropTypes.bool,
onCloseClicked: React.PropTypes.func,
onOverlayClicked: React.PropTypes.func
},
render () {
const { url, ...other } = this.props;
return (
<Lightbox {...other}>
<img src={url} style={imageStyle} />
</Lightbox>
);
}
});
export default connect(mapStateToProps, mapDispatchToProps)(Modal);

View File

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

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

@@ -1,51 +1,16 @@
import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
import { replyCompose } from '../../../actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite
} from '../../../actions/interactions';
import { expandTimeline } from '../../../actions/timelines';
import { selectStatus } from '../../../reducers/timelines';
import { deleteStatus } from '../../../actions/statuses';
import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
import { expandTimeline } from '../../../actions/timelines';
import Immutable from 'immutable';
const mapStateToProps = function (state, props) {
return {
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
me: state.getIn(['timelines', 'me'])
};
};
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', props.type], Immutable.List())
});
const mapDispatchToProps = function (dispatch, props) {
return {
onReply (status) {
dispatch(replyCompose(status));
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onReblog (status) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
}
},
onScrollToBottom () {
dispatch(expandTimeline(props.type));
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
dispatch(expandTimeline(props.type, props.id));
}
};
};

View File

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

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

@@ -0,0 +1,89 @@
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
FOLLOWERS_FETCH_SUCCESS,
FOLLOWING_FETCH_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import {
REBLOG_SUCCESS,
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS,
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS
} from '../actions/interactions';
import {
TIMELINE_REFRESH_SUCCESS,
TIMELINE_UPDATE,
TIMELINE_EXPAND_SUCCESS
} from '../actions/timelines';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import Immutable from 'immutable';
const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
const normalizeAccounts = (state, accounts) => {
accounts.forEach(account => {
state = normalizeAccount(state, account);
});
return state;
};
const normalizeAccountFromStatus = (state, status) => {
state = normalizeAccount(state, status.account);
if (status.reblog && status.reblog.account) {
state = normalizeAccount(state, status.reblog.account);
}
return state;
};
const normalizeAccountsFromStatuses = (state, statuses) => {
statuses.forEach(status => {
state = normalizeAccountFromStatus(state, status);
});
return state;
};
const initialState = Immutable.Map();
export default function accounts(state = initialState, action) {
switch(action.type) {
case ACCOUNT_SET_SELF:
case ACCOUNT_FETCH_SUCCESS:
case FOLLOW_SUBMIT_SUCCESS:
return normalizeAccount(state, action.account);
case SUGGESTIONS_FETCH_SUCCESS:
case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWING_FETCH_SUCCESS:
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeAccountFromStatus(state, action.response);
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
default:
return state;
}
};

View File

@@ -2,6 +2,7 @@ import {
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
COMPOSE_SUBMIT_FAIL,
@@ -9,11 +10,13 @@ import {
COMPOSE_UPLOAD_SUCCESS,
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
COMPOSE_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import Immutable from 'immutable';
import Immutable from 'immutable';
const initialState = Immutable.Map({
text: '',
@@ -21,7 +24,8 @@ const initialState = Immutable.Map({
is_submitting: false,
is_uploading: false,
progress: 0,
media_attachments: Immutable.List([]),
media_attachments: Immutable.List(),
suggestions: [],
me: null
});
@@ -32,7 +36,7 @@ function statusToTextMentions(state, status) {
if (status.getIn(['account', 'id']) !== me) {
set = set.add(`@${status.getIn(['account', 'acct'])} `);
}
return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
};
@@ -92,6 +96,12 @@ export default function compose(state = initialState, action) {
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.set('suggestions', []);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', action.accounts);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);

View File

@@ -3,7 +3,7 @@ import {
FOLLOW_SUBMIT_REQUEST,
FOLLOW_SUBMIT_SUCCESS,
FOLLOW_SUBMIT_FAIL
} from '../actions/follow';
} from '../actions/follow';
import Immutable from 'immutable';
const initialState = Immutable.Map({

View File

@@ -5,6 +5,11 @@ import compose from './compose';
import follow from './follow';
import notifications from './notifications';
import { loadingBarReducer } from 'react-redux-loading-bar';
import modal from './modal';
import user_lists from './user_lists';
import accounts from './accounts';
import statuses from './statuses';
import relationships from './relationships';
export default combineReducers({
timelines,
@@ -13,4 +18,9 @@ export default combineReducers({
follow,
notifications,
loadingBar: loadingBarReducer,
modal,
user_lists,
accounts,
statuses,
relationships
});

View File

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

View File

@@ -0,0 +1,21 @@
import { MEDIA_OPEN, MODAL_CLOSE } from '../actions/modal';
import Immutable from 'immutable';
const initialState = Immutable.Map({
url: '',
open: false
});
export default function modal(state = initialState, action) {
switch(action.type) {
case MEDIA_OPEN:
return state.withMutations(map => {
map.set('url', action.url);
map.set('open', true);
});
case MODAL_CLOSE:
return state.set('open', false);
default:
return state;
}
};

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

@@ -0,0 +1,34 @@
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS
} from '../actions/accounts';
import Immutable from 'immutable';
const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship));
const normalizeRelationships = (state, relationships) => {
relationships.forEach(relationship => {
state = normalizeRelationship(state, relationship);
});
return state;
};
const initialState = Immutable.Map();
export default function relationships(state = initialState, action) {
switch(action.type) {
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
default:
return state;
}
};

View File

@@ -0,0 +1,73 @@
import {
REBLOG_SUCCESS,
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import {
TIMELINE_REFRESH_SUCCESS,
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPAND_SUCCESS
} from '../actions/timelines';
import {
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
import Immutable from 'immutable';
const normalizeStatus = (state, status) => {
status.account = status.account.id;
if (status.reblog && status.reblog.id) {
state = normalizeStatus(state, status.reblog);
status.reblog = status.reblog.id;
}
return state.set(status.id, Immutable.fromJS(status));
};
const normalizeStatuses = (state, statuses) => {
statuses.forEach(status => {
state = normalizeStatus(state, status);
});
return state;
};
const deleteStatus = (state, id, references) => {
references.forEach(ref => {
state = deleteStatus(state, ref[0], []);
});
return state.delete(id);
};
const initialState = Immutable.Map();
export default function statuses(state = initialState, action) {
switch(action.type) {
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeStatus(state, action.status);
case REBLOG_SUCCESS:
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, action.response);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
default:
return state;
}
};

View File

@@ -3,243 +3,167 @@ import {
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPAND_SUCCESS
} from '../actions/timelines';
} from '../actions/timelines';
import {
REBLOG_SUCCESS,
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
} from '../actions/interactions';
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
} from '../actions/accounts';
import {
STATUS_FETCH_SUCCESS,
STATUS_DELETE_SUCCESS
} from '../actions/statuses';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import Immutable from 'immutable';
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import Immutable from 'immutable';
const initialState = Immutable.Map({
home: Immutable.List([]),
mentions: Immutable.List([]),
statuses: Immutable.Map(),
accounts: Immutable.Map(),
home: Immutable.List(),
mentions: Immutable.List(),
public: Immutable.List(),
tag: Immutable.List(),
accounts_timelines: Immutable.Map(),
me: null,
ancestors: Immutable.Map(),
descendants: Immutable.Map(),
relationships: Immutable.Map()
descendants: Immutable.Map()
});
export function selectStatus(state, id) {
let status = state.getIn(['timelines', 'statuses', id], null);
const normalizeStatus = (state, status) => {
const replyToId = status.get('in_reply_to_id');
const id = status.get('id');
if (status === null) {
return null;
}
status = status.set('account', selectAccount(state, status.get('account')));
if (status.get('reblog') !== null) {
status = status.set('reblog', selectStatus(state, status.get('reblog')));
}
return status;
};
export function selectAccount(state, id) {
let account = state.getIn(['timelines', 'accounts', id], null);
if (account === null) {
return null;
}
return account.set('relationship', state.getIn(['timelines', 'relationships', id]));
};
function normalizeStatus(state, status) {
// Separate account
let account = status.get('account');
status = status.set('account', account.get('id'));
// Separate reblog, repeat for reblog
let reblog = status.get('reblog');
if (reblog !== null) {
status = status.set('reblog', reblog.get('id'));
state = normalizeStatus(state, reblog);
}
// Replies
if (status.get('in_reply_to_id')) {
state = state.updateIn(['descendants', status.get('in_reply_to_id')], set => {
if (!Immutable.OrderedSet.isOrderedSet(set)) {
return Immutable.OrderedSet([status.get('id')]);
} else {
return set.add(status.get('id'));
}
});
}
return state.withMutations(map => {
if (status.get('in_reply_to_id')) {
map.updateIn(['descendants', status.get('in_reply_to_id')], Immutable.OrderedSet(), set => set.add(status.get('id')));
map.updateIn(['ancestors', status.get('id')], Immutable.OrderedSet(), set => set.add(status.get('in_reply_to_id')));
if (replyToId) {
if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
}
map.setIn(['accounts', account.get('id')], account);
map.setIn(['statuses', status.get('id')], status);
});
};
function normalizeTimeline(state, timeline, statuses) {
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
state = state.setIn([timeline, i], status.get('id'));
});
return state;
};
function appendNormalizedTimeline(state, timeline, statuses) {
let moreIds = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id'));
});
return state.update(timeline, list => list.push(...moreIds));
};
function normalizeAccountTimeline(state, accountId, statuses) {
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
state = state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.set(i, status.get('id')));
});
return state;
};
function appendNormalizedAccountTimeline(state, accountId, statuses) {
let moreIds = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List(), list => list.push(...moreIds));
};
function updateTimeline(state, timeline, status) {
state = normalizeStatus(state, status);
state = state.update(timeline, list => list.unshift(status.get('id')));
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List(), list => list.unshift(status.get('id')));
return state;
};
function deleteStatus(state, id) {
const status = state.getIn(['statuses', id]);
if (!status) {
return state;
if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
}
}
return state;
};
const normalizeTimeline = (state, timeline, statuses, replace = false) => {
let ids = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
ids = ids.set(i, status.get('id'));
});
return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids)));
};
const appendNormalizedTimeline = (state, timeline, statuses) => {
let moreIds = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id'));
});
return state.update(timeline, Immutable.List(), list => list.push(...moreIds));
};
const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
let ids = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
ids = ids.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
};
const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
let moreIds = Immutable.List([]);
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id'));
});
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
};
const updateTimeline = (state, timeline, status, references) => {
state = normalizeStatus(state, status);
state = state.update(timeline, Immutable.List(), list => {
if (list.includes(status.get('id'))) {
return list;
}
const reblogOfId = status.getIn(['reblog', 'id'], null);
if (reblogOfId !== null) {
list = list.filterNot(itemId => references.includes(itemId));
}
return list.unshift(status.get('id'));
});
return state;
};
const deleteStatus = (state, id, accountId, references) => {
// Remove references from timelines
['home', 'mentions'].forEach(function (timeline) {
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
state = state.update(timeline, list => list.filterNot(item => item === id));
});
// Remove references from account timelines
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id));
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove reblogs of deleted status
const references = state.get('statuses').filter(item => item.get('reblog') === id);
references.forEach(referencingId => {
state = deleteStatus(state, referencingId);
// Remove references from context
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
});
// Remove normalized status
return state.deleteIn(['statuses', id]);
state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => {
state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
});
state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
// Remove reblogs of deleted status
references.forEach(ref => {
state = deleteStatus(state, ref[0], ref[1], []);
});
return state;
};
function normalizeAccount(state, account, relationship) {
if (relationship) {
state = normalizeRelationship(state, relationship);
}
return state.setIn(['accounts', account.get('id')], account);
};
function normalizeRelationship(state, relationship) {
return state.setIn(['relationships', relationship.get('id')], relationship);
};
function setSelf(state, account) {
state = normalizeAccount(state, account);
return state.set('me', account.get('id'));
};
function normalizeContext(state, status, ancestors, descendants) {
state = normalizeStatus(state, status);
let ancestorsIds = ancestors.map(ancestor => {
state = normalizeStatus(state, ancestor);
return ancestor.get('id');
}).toOrderedSet();
let descendantsIds = descendants.map(descendant => {
state = normalizeStatus(state, descendant);
return descendant.get('id');
}).toOrderedSet();
const normalizeContext = (state, id, ancestors, descendants) => {
const ancestorsIds = ancestors.map(ancestor => ancestor.get('id'));
const descendantsIds = descendants.map(descendant => descendant.get('id'));
return state.withMutations(map => {
map.setIn(['ancestors', status.get('id')], ancestorsIds);
map.setIn(['descendants', status.get('id')], descendantsIds);
map.setIn(['ancestors', id], ancestorsIds);
map.setIn(['descendants', id], descendantsIds);
});
};
export default function timelines(state = initialState, action) {
switch(action.type) {
case TIMELINE_REFRESH_SUCCESS:
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.replace);
case TIMELINE_EXPAND_SUCCESS:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
case TIMELINE_DELETE:
case STATUS_DELETE_SUCCESS:
return deleteStatus(state, action.id);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, Immutable.fromJS(action.response));
case ACCOUNT_SET_SELF:
return setSelf(state, Immutable.fromJS(action.account));
case ACCOUNT_FETCH_SUCCESS:
case FOLLOW_SUBMIT_SUCCESS:
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
case STATUS_FETCH_SUCCESS:
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));
return deleteStatus(state, action.id, action.accountId, action.references);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
default:

View File

@@ -0,0 +1,35 @@
import {
FOLLOWERS_FETCH_SUCCESS,
FOLLOWING_FETCH_SUCCESS
} from '../actions/accounts';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import {
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS
} from '../actions/interactions';
import Immutable from 'immutable';
const initialState = Immutable.Map({
followers: Immutable.Map(),
following: Immutable.Map(),
suggestions: Immutable.List(),
reblogged_by: Immutable.Map(),
favourited_by: Immutable.Map()
});
export default function userLists(state = initialState, action) {
switch(action.type) {
case FOLLOWERS_FETCH_SUCCESS:
return state.setIn(['followers', action.id], Immutable.List(action.accounts.map(item => item.id)));
case FOLLOWING_FETCH_SUCCESS:
return state.setIn(['following', action.id], Immutable.List(action.accounts.map(item => item.id)));
case SUGGESTIONS_FETCH_SUCCESS:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id)));
case REBLOGS_FETCH_SUCCESS:
return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
default:
return state;
}
};

View File

@@ -0,0 +1,68 @@
import { createSelector } from 'reselect'
import Immutable from 'immutable';
const getStatuses = state => state.get('statuses');
const getAccounts = state => state.get('accounts');
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
export const makeGetAccount = () => {
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
if (base === null) {
return null;
}
return base.set('relationship', relationship);
});
};
const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
export const makeGetStatus = () => {
return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
if (base === null) {
return null;
}
return assembleStatus(base.get('id'), statuses, accounts);
});
};
const assembleStatus = (id, statuses, accounts) => {
let status = statuses.get(id, null);
let reblog = null;
if (status === null) {
return null;
}
if (status.get('reblog', null) !== null) {
reblog = statuses.get(status.get('reblog'), null);
if (reblog !== null) {
reblog = reblog.set('account', accounts.get(reblog.get('account')));
} else {
return null;
}
}
return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
};
const getNotificationsBase = state => state.get('notifications');
export const getNotifications = createSelector([getNotificationsBase], (base) => {
let arr = [];
base.forEach(item => {
arr.push({
message: item.get('message'),
title: item.get('title'),
key: item.get('key'),
dismissAfter: 5000
});
});
return arr;
});

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

@@ -6,7 +6,8 @@
max-width: 600px;
margin: 0 auto;
color: #9baec8;
padding-top: 200px;
padding-top: 50px;
padding-bottom: 50px;
}
h1 {
@@ -24,10 +25,46 @@
}
}
p {
h2 {
font: 24px/28px 'Judson', sans-serif;
font-weight: 300;
margin-bottom: 20px;
color: #fff;
}
h3 {
font: 20px/28px 'Judson', sans-serif;
font-weight: 300;
margin-bottom: 20px;
color: #d9e1e8;
}
ul, ol {
list-style: inherit;
margin-left: 20px;
&[type='a'] {
list-style-type: lower-alpha;
}
&[type='i'] {
list-style-type: lower-roman;
}
}
li > ol, li > ul {
margin-top: 20px;
}
p, li {
font: 20px/28px 'Judson', sans-serif;
font-weight: 300;
margin-bottom: 26px;
a {
color: #2b90d9;
text-decoration: underline;
}
}
em {
@@ -39,6 +76,32 @@
font-weight: 300;
}
.screenshot {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
margin-bottom: 26px;
img {
max-width: 100%;
height: auto;
display: block;
}
}
.actions {
overflow: hidden;
.info {
float: right;
text-align: right;
line-height: 36px;
a {
color: #9baec8;
text-decoration: underline;
}
}
}
@media screen and (max-width: 360px) {
.wrapper {
padding: 20px;

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

@@ -42,7 +42,8 @@
.icon-button {
color: #616b86;
cursor: pointer;
border: none;
background: transparent;
&:hover {
color: #717b98;
@@ -58,6 +59,10 @@
}
}
.lightbox .icon-button {
color: #282c37;
}
.compose-form__textarea, .follow-form__input {
background: #fff;
@@ -66,11 +71,28 @@
}
}
.emojione {
display: inline-block;
font-size: inherit;
vertical-align: middle;
margin: -.2ex .15em .2ex;
img {
width: auto;
}
}
.status__content, .reply-indicator__content {
font-size: 15px;
line-height: 20px;
word-wrap: break-word;
font-weight: 300;
overflow: hidden;
.emojione {
width: 18px;
height: 18px;
}
p {
margin-bottom: 20px;
@@ -104,6 +126,11 @@
.status__content {
font-size: 19px;
line-height: 24px;
.emojione {
width: 22px;
height: 22px;
}
}
}
@@ -116,17 +143,40 @@
}
}
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime {
.account__header__content {
word-wrap: break-word;
font-weight: 300;
overflow: hidden;
p {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: inherit;
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
text-decoration: none;
}
.status__display-name {
.status__display-name, .account__display-name {
strong {
color: #fff;
}
}
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name {
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name, .account__display-name {
&:hover {
strong {
text-decoration: underline;
@@ -134,6 +184,12 @@
}
}
.account__display-name {
strong {
display: block;
}
}
.detailed-status__display-name {
color: #d9e1e8;
line-height: 24px;
@@ -197,7 +253,7 @@
font-size: 13px;
display: block;
padding: 6px 16px;
width: 120px;
width: 100px;
text-decoration: none;
background: #d9e1e8;
color: #282c37;
@@ -208,3 +264,89 @@
}
}
}
.static-content {
padding: 10px;
padding-top: 20px;
color: #616b86;
h1 {
font-size: 16px;
font-weight: 500;
margin-bottom: 40px;
text-align: center;
}
p {
font-size: 13px;
margin-bottom: 20px;
}
}
.columns-area {
margin: 10px;
margin-left: 0;
flex-direction: row;
}
.column {
width: 330px;
}
.drawer {
width: 280px;
}
.column, .drawer {
margin-left: 10px;
flex: 0 0 auto;
overflow: hidden;
}
@media screen and (max-width: 1024px) {
.column, .drawer {
width: 100%;
margin: 0;
flex: 1 1 100%;
}
.columns-area {
margin: 10px;
flex-direction: column;
}
}
.react-autosuggest__container {
position: relative;
}
.react-autosuggest__suggestions-container {
position: absolute;
top: 100%;
width: 100%;
z-index: 99;
}
.react-autosuggest__suggestions-list {
background: #9baec8;
color: #282c37;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
font-size: 14px;
}
.react-autosuggest__suggestion {
padding: 10px;
cursor: pointer;
}
.react-autosuggest__suggestion--focused {
background: #2b90d9;
color: #fff;
}
.scrollable {
overflow-y: scroll;
overflow-x: hidden;
flex: 1 1 auto;
-webkit-overflow-scrolling: touch;
}

View File

@@ -0,0 +1,182 @@
code {
font-family: 'Roboto Mono', monospace;
font-weight: 400;
}
.form-container {
max-width: 400px;
padding: 20px;
margin: 0 auto;
}
.simple_form {
.input {
margin-bottom: 15px;
}
.input.file {
padding: 15px 0;
margin-bottom: 0;
label {
font-family: 'Roboto';
font-size: 16px;
color: #fff;
width: 100px;
display: inline-block;
}
input[type=file] {
width: 280px;
}
}
.fields-group {
margin-bottom: 25px;
}
.input.boolean {
margin-bottom: 5px;
label {
font-family: 'Roboto';
font-size: 14px;
color: #9baec8;
}
input[type=checkbox] {
display: inline-block;
margin-bottom: -13px;
}
}
input[type=text], input[type=email], input[type=password], textarea {
background: transparent;
box-sizing: border-box;
border: 0;
border-bottom: 2px solid #9baec8;
border-radius: 2px 2px 0 0;
padding: 7px 4px;
font-size: 16px;
color: #fff;
display: block;
width: 100%;
outline: 0;
font-family: 'Roboto';
&:invalid {
box-shadow: none;
}
&:focus:invalid {
border-bottom-color: #df405a;
}
&:required:valid {
border-bottom-color: #79bd9a;
}
&:active, &:focus {
border-bottom-color: #2b90d9;
background: rgba(0, 0, 0, 0.1);
}
}
.input.field_with_errors {
input[type=text], input[type=email], input[type=password] {
border-bottom-color: #df405a;
}
.error {
font-weight: 500;
color: #df405a;
}
}
.actions {
margin-top: 30px;
}
button {
display: block;
width: 100%;
border: 0;
border-radius: 4px;
background: #2b90d9;
color: #fff;
font-size: 18px;
padding: 10px;
text-transform: uppercase;
cursor: pointer;
font-weight: 500;
outline: 0;
margin-bottom: 10px;
&:hover {
background-color: lighten(#2b90d9, 5%);
}
&:active, &:focus {
position: relative;
top: 1px;
background-color: darken(#2b90d9, 5%);
}
&.negative {
background: #df405a;
&:hover {
background-color: lighten(#df405a, 5%);
}
&:active, &:focus {
background-color: darken(#df405a, 5%);
}
}
}
}
.flash-message {
background: #282c37;
color: #9baec8;
border-radius: 4px;
padding: 15px 10px;
margin-bottom: 30px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
text-align: center;
strong {
font-weight: 500;
}
}
.form-footer {
margin-top: 30px;
text-align: center;
a {
color: #9baec8;
text-decoration: none;
&:hover {
color: #d9e1e8;
text-decoration: underline;
}
}
}
.oauth-prompt {
margin-bottom: 30px;
text-align: center;
color: #9baec8;
h2 {
font-size: 16px;
margin-bottom: 30px;
}
strong {
color: #d9e1e8;
font-weight: 500;
}
}

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

@@ -1,4 +1,20 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
protected
def hydrate_status(encoded_message)
message = ActiveSupport::JSON.decode(encoded_message)
return [nil, message] if message['type'] == 'delete'
status = Status.find_by(id: message['id'])
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
[status, message]
end
def filter?(status)
!status.nil? && FeedManager.instance.filter?(:public, status, current_user.account)
end
end
end

View File

@@ -0,0 +1,11 @@
class HashtagChannel < ApplicationCable::Channel
def subscribed
tag = params[:tag].downcase
stream_from "timeline:hashtag:#{tag}", lambda { |encoded_message|
status, message = hydrate_status(encoded_message)
next if filter?(status)
transmit message
}
end
end

View File

@@ -0,0 +1,9 @@
class PublicChannel < ApplicationCable::Channel
def subscribed
stream_from 'timeline:public', lambda { |encoded_message|
status, message = hydrate_status(encoded_message)
next if filter?(status)
transmit message
}
end
end

View File

@@ -2,8 +2,4 @@ class TimelineChannel < ApplicationCable::Channel
def subscribed
stream_from "timeline:#{current_user.account_id}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

View File

@@ -1,5 +1,16 @@
class AboutController < ApplicationController
before_action :set_body_classes
def index
end
def terms
@state = 'TBD'
end
private
def set_body_classes
@body_classes = 'about-body'
end
end

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

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