Compare commits

...

607 Commits
v0.8 ... v0.9.9

Author SHA1 Message Date
Eugen Rochko
f051c2e813 Fix statsd null backend not being initialized properly 2017-01-19 09:37:07 +01:00
Eugen
8b9206f7d9 Try to avoid any potential loops 2017-01-19 02:14:57 +01:00
Eugen Rochko
306eb6e9c9 Add optional StatsD performance tracking 2017-01-18 23:44:29 +01:00
Eugen Rochko
7cfd5b680a Fix forgotten change of settings 2017-01-17 20:37:54 +01:00
Eugen Rochko
c468446f4c Fix boops? 2017-01-17 20:28:32 +01:00
Eugen Rochko
cde0476ca2 Fix #487 2017-01-17 20:11:16 +01:00
Eugen Rochko
fcb5a85cdd Add sounds for notifications. Boop by @jk@mastodon.social 2017-01-17 20:09:03 +01:00
Eugen
d7a7baa9a7 Merge pull request #489 from ineffyble/master
Add Heroku deployment support
2017-01-17 18:46:53 +01:00
Effy Elden
ab4f5f5da5 Add Heroku deployment support 2017-01-17 22:00:03 +11:00
Eugen Rochko
6cf44ca92c Improve how the list entry Account component looks when target is blocked/follow is requested 2017-01-16 19:36:32 +01:00
Eugen
99fe89026c Merge pull request #486 from blackle/replytimeline
Show 'do not display in public timeline' when replying to self
2017-01-16 16:38:08 +01:00
blackle
889709a2a6 Show 'do not display in public timeline' when replying to self 2017-01-16 10:26:44 -05:00
Eugen Rochko
1a33e4042e Fix upload file input being re-rendered needlessly 2017-01-16 14:21:55 +01:00
Eugen Rochko
7d53ee73f3 Fix #238 - Add "favourites" column 2017-01-16 13:28:25 +01:00
Eugen Rochko
da5d366230 Fix #414 - Improve lightbox, add loading indicator 2017-01-16 12:04:02 +01:00
Eugen Rochko
a78148f763 Merge branch 'blackle-sensitive' 2017-01-16 11:37:35 +01:00
Eugen Rochko
2ae0fb4419 Merge branch 'sensitive' of https://github.com/blackle/mastodon into blackle-sensitive 2017-01-16 11:36:02 +01:00
Eugen Rochko
0439c7d58b Move follow requests link above logout link in getting started column 2017-01-16 11:35:32 +01:00
blackle
144402ec7e Show visibility toggle on media gallery for sensitive images 2017-01-15 20:27:08 -05:00
Eugen
3778355454 Merge pull request #478 from blackle/backflip
Change boost sprite to look like fa-retweet
2017-01-15 18:45:53 +01:00
blackle
383114add3 Change boost sprite to look like fa-retweet 2017-01-15 11:40:05 -05:00
Eugen Rochko
926459fc0a Fix typo introduced in #470 which made unlisted toggle only fade in/out and not also collapse 2017-01-15 14:59:25 +01:00
Eugen Rochko
7d7a11250c Fix #427 - don't suggest hashtag if the search value contains a space 2017-01-15 14:47:39 +01:00
Eugen Rochko
1d5cbfa356 Fix #449 - don't do relationships fetch for empty array of IDs 2017-01-15 14:47:39 +01:00
Eugen Rochko
cc1eccc8bc Fix #466 - when logged in, make "get started" link to the frontpage instead of sign up 2017-01-15 14:47:39 +01:00
Eugen
16f9490d33 Merge pull request #476 from ineffyble/fix-paperclip-s3-acl
Fix S3 default settings
2017-01-15 14:29:47 +01:00
Eugen Rochko
bfec9aaee0 Merge branch 'ineffyble-feature/toot-app-source' 2017-01-15 14:04:56 +01:00
Eugen Rochko
e9737c2235 Fix tests, add applications to eager loading/cache for statuses, fix
application website validation, don't link to app website if website isn't set,
also comment out animated boost icon from #464 until it's consistent with non-animated version
2017-01-15 14:01:33 +01:00
Eugen Rochko
ab165547fd Merge branch 'feature/toot-app-source' of https://github.com/ineffyble/mastodon into ineffyble-feature/toot-app-source 2017-01-15 13:23:47 +01:00
Eugen
1f7c0ad8d3 Merge pull request #464 from blackle/backflip
Make boost button spin around
2017-01-15 13:19:15 +01:00
Effy Elden
a097dd489b Change default S3 ACL string used by Paperclip from 'public' (which is invalid) to 'public-read' 2017-01-15 20:58:46 +11:00
Eugen
777f527d86 Merge pull request #474 from ineffyble/add-back-link-to-settings
Add a 'Back to Mastodon' link to footer of settings pages
2017-01-15 02:15:37 +01:00
Effy Elden
e45fed58cb Add a textual 'Back to Mastodon' link in footer of settings pages 2017-01-15 11:03:35 +11:00
blackle
a67ffcbf56 Make boost button arrows spin around 2017-01-14 17:56:24 -05:00
Effy Elden
f81dc7a33a Merge branch 'feature/toot-app-source' of github.com:ineffyble/mastodon into feature/toot-app-source 2017-01-15 09:12:11 +11:00
Effy Elden
6c002cf615 Test for presence of Application in haml before trying to render details, to fix errors identified by CI 2017-01-15 09:10:44 +11:00
Effy Elden
65122798b2 Fix typo in rel 2017-01-15 09:04:14 +11:00
Effy Elden
d6bc0e8db4 Add tracking of OAuth app that posted a status, extend OAuth apps to have optional website field, add application details to API, show application name and website on detailed status views. Resolves #11 2017-01-15 08:58:50 +11:00
Eugen
88801f7554 Merge pull request #470 from ineffyble/fix/412-hide-public-timeline-option-for-replies
Hide 'Do not display on public timeline' for reply toots
2017-01-14 20:33:10 +01:00
Effy Elden
32d756fb22 Hide 'Do not display on public timeline' for reply toots. Fixes #412. 2017-01-15 06:10:23 +11:00
Eugen
f63f0c4625 Fix too late return 2017-01-14 02:22:16 +01:00
Eugen Rochko
7cde08e30b Improve extended about page layout 2017-01-13 20:27:02 +01:00
Eugen Rochko
c01dd089ff Adding about/more page with extended information that can be set up by an admin 2017-01-13 20:16:38 +01:00
Eugen Rochko
e25170f960 Add extended about page stub 2017-01-13 03:24:41 +01:00
Eugen Rochko
2939e9898b Extend rails-settings-cached to merge db-saved hash values with defaults 2017-01-13 02:42:22 +01:00
Eugen Rochko
ca50ceeaf0 Add settings for contact information 2017-01-12 20:54:14 +01:00
Eugen Rochko
b11fdc3ae3 Migrate from ledermann/rails-settings to rails-settings-cached which allows global settings
with YAML-defined defaults. Add admin page for editing global settings. Add "site_description"
setting that would show as a paragraph on the frontpage
2017-01-12 20:46:24 +01:00
Eugen
babc6a1528 Merge pull request #461 from vjackson725/master
fix Vagrantfile so that the correct ruby is installed
2017-01-12 13:50:17 +01:00
vjackson725
91dc21c469 fix Vagrantfile so that the correct ruby is installed
Prior to this commit, the vagrantfile installs ruby 2.3.3. Due to commit mastodon@7951e7f, this no longer works.

Change the provision scrip so that 2.3.1 is installed via rbenv. Note this drastically increases provision time.
2017-01-12 23:20:55 +11:00
Eugen Rochko
8f54a8851a Add rake task for clearing out media from silenced accounts 2017-01-12 04:19:04 +01:00
Eugen Rochko
4a2ee43e80 Fix #457 - escape JSON in INITIAL_STATE (this bug only ever allowed a user to xss themselves rather than anyone else) 2017-01-12 03:54:50 +01:00
Eugen Rochko
7951e7ffd5 Add ruby version to Gemfile, move devDependencies in package.json to dependencies,
fix bug in process feed service
2017-01-11 15:39:31 +01:00
Eugen
10739df458 Merge pull request #456 from ineffyble/fix-repo-url
Fix repo URL
2017-01-11 14:19:19 +01:00
Eugen Rochko
3ad0496ccb Better animations 2017-01-11 04:21:49 +01:00
Effy Elden
f876a8681d Update the Mastodon repository URL from Gargron/ to tootsuite/ in various places 2017-01-11 13:24:14 +11:00
Eugen
4292cf60ae Fix env variable name 2017-01-10 22:01:13 +01:00
Eugen Rochko
18b11100e7 Fix issue when settings are not defined for column type 2017-01-10 17:33:32 +01:00
Eugen Rochko
312c51b5c8 Home column filters 2017-01-10 17:25:10 +01:00
Eugen Rochko
1e9d2c4b1e Add "not found" component to UI 2017-01-10 13:50:59 +01:00
Eugen
a1db2a191b Add link to list of apps 2017-01-10 12:23:30 +01:00
Eugen
67a31454ad Merge pull request #450 from blackle/master
simplify emojification on public pages with .emojify class
2017-01-10 12:21:06 +01:00
blackle
917cf0bf5d simplify emojification on public pages with .emojify class 2017-01-09 22:41:47 -05:00
Eugen Rochko
205ba00017 Fix sandboxed iOS login again 2017-01-09 19:43:53 +01:00
Eugen Rochko
5d558c14b4 Merge branch 'blackle-master' 2017-01-09 14:13:46 +01:00
Eugen Rochko
def1f8c5b3 Merge branch 'master' of https://github.com/blackle/mastodon into blackle-master 2017-01-09 14:12:40 +01:00
Eugen Rochko
d6a456dc71 Merge branch 'blackle-dropdown' 2017-01-09 14:12:00 +01:00
Eugen Rochko
3bddd647e0 Merge branch 'dropdown' of https://github.com/blackle/mastodon into blackle-dropdown 2017-01-09 14:10:32 +01:00
Eugen Rochko
75f80bef10 Persist UI settings, add missing localizations for German 2017-01-09 14:00:55 +01:00
Eugen Rochko
23ebf60b95 Improve initialState loading 2017-01-09 12:37:53 +01:00
blackle
131bae89fd Generate key for each input so we can upload the same file even after deleting 2017-01-08 21:00:13 -05:00
blackle
d64c454cfe Switch to compose view when tapping 'mention' in dropdown on mobile 2017-01-08 17:12:00 -05:00
Eugen Rochko
2e71bb031b Fix Paperclip timeout setting. Fix bug introduced in #437 2017-01-08 19:12:54 +01:00
Eugen
38bc85e695 Merge pull request #443 from blackle/master
Add call-to developers on getting started page
2017-01-08 13:36:02 +01:00
Eugen
a8e30060ae Merge pull request #446 from blackle/dropdown2
Expand dropdown leftways on status view
2017-01-08 13:34:27 +01:00
blackle
05e964688d Expand dropdown leftways on status view 2017-01-08 06:32:37 -05:00
blackle
b4f09bae1d Add call-to developers on getting started page 2017-01-08 00:23:22 -05:00
Eugen
69643338f6 Merge pull request #437 from krainboltgreene/patch-1
Simplifying followers mappings
2017-01-08 04:14:54 +01:00
Eugen
e8d6f6c8c1 Merge pull request #436 from krainboltgreene/patch-2
Less complicated determination of subscription
2017-01-08 04:00:22 +01:00
Eugen
db8a088502 Update badges 2017-01-08 03:47:33 +01:00
Kurtis Rainbolt-Greene
abe3ae1cc2 Simplifying followers mappings
Deduplicating some logic and switching to pluck, to avoid pulling the entire model in memory.
2017-01-07 18:09:00 -08:00
Kurtis Rainbolt-Greene
4a7e3e5082 Less complicated determination 2017-01-07 17:59:51 -08:00
Eugen
6d097d559b Merge pull request #434 from vjackson725/fix_vagrantfile
Fix Vagrantfile to include yarn
2017-01-08 02:58:43 +01:00
Eugen
4447c4c7c8 Merge pull request #435 from krainboltgreene/patch-1
Using "rails style" order arguments
2017-01-08 02:58:19 +01:00
Kurtis Rainbolt-Greene
e5d9009d71 Using "rails style" order arguments 2017-01-07 17:55:40 -08:00
vjackson725
db3d5d811c Fix Vagrantfile to include yarn
The current Vagrantfile fails to build upon provisioning, as it lacks yarn.

Change the Vagrantfile to add the yarn repository, and install yarn.
2017-01-08 12:38:04 +11:00
Eugen
7a0a13ab53 Merge pull request #432 from blackle/volume-icon
Show correct volume icons on video player
2017-01-08 02:36:12 +01:00
blackle
8a571158c9 Show correct volume icons on video player 2017-01-07 20:23:15 -05:00
Eugen
cec7e69827 Merge pull request #430 from blackle/logout_mobile
Show logout button on getting started so it's available on mobile
2017-01-08 01:24:06 +01:00
blackle
e1ca354956 Show logout button on getting started so it's available on mobile 2017-01-07 18:41:57 -05:00
Eugen
679aa35e15 Merge pull request #429 from blackle/emojify_usernames
Emojify display names in notifcations
2017-01-08 00:19:24 +01:00
blackle
57ff221c0f Emojify display names in notifcations 2017-01-07 18:16:14 -05:00
Eugen
165df323ae Merge pull request #428 from blackle/contrast
Improve contrast of bio and username text in account info
2017-01-07 22:51:15 +01:00
blackle
61211b509c Improve contrast of bio and username text in account info 2017-01-07 16:39:30 -05:00
Eugen
2fbb38e4b2 Merge pull request #425 from ineffyble/vagrant
Add Vagrant support for easy development environments
2017-01-07 18:31:59 +01:00
Eugen Rochko
be6ae3546f Replace getting started icon on desktop nav bar with asterisk instead of hamburger 2017-01-07 15:46:39 +01:00
Eugen Rochko
5c7add2176 Fix #147 - Unreblogging will leave original status in feeds 2017-01-07 15:44:22 +01:00
Eugen Rochko
7ddec6e7c3 Add read timeout to paperclip when it's downloading remote images 2017-01-07 15:43:56 +01:00
Effy Elden
11ea7336e9 Tweak Vagrant info in README 2017-01-08 01:30:48 +11:00
Effy Elden
46fb634c79 Improve Vagrantfile, load environment variables from .env.vagrant, always forward localhost:3000 2017-01-08 01:28:49 +11:00
Effy Elden
f1289ca3c0 Add Vagrant development environment support 2017-01-07 23:22:24 +11:00
Eugen Rochko
989c3f4002 Add tab bar alternative to desktop UI, upgrade react & react-redux 2017-01-06 22:09:55 +01:00
Eugen Rochko
1bfbce7b45 Clean up h-card mess of divs 2017-01-06 20:24:51 +01:00
Eugen Rochko
72c3a41bef Fix h-card classes and remote follow button appearing when it shouldn't 2017-01-06 20:15:24 +01:00
Eugen Rochko
8b28b82141 Merge branch 'myfreeweb-master' 2017-01-06 20:09:34 +01:00
Greg V
18deeb9db5 Add Microformats2 markup
h-feed, h-card and h-entry
2017-01-06 18:10:10 +03:00
Eugen
92bd5f62f6 Merge pull request #417 from ineffyble/twitter-card-metadata
Twitter Cards support
2017-01-06 01:16:45 +01:00
Eugen
6917099a6a Merge pull request #418 from ticky/upgrade-emojione
Upgrade EmojiOne to 2.2.7
2017-01-06 01:13:34 +01:00
Eugen Rochko
7b9f8766e8 Fix #416 - Generate random unique 14-byte (19 characters) shortcodes
for local attachments, use them in URLs. Check status privacy
before redirecting to actual file.
2017-01-06 00:29:12 +01:00
Jessica Stokes
28a2f79dff Upgrade EmojiOne to 2.2.7
Adds support for, among other things, 🏳️‍🌈
2017-01-05 15:16:13 -08:00
Effy Elden
819bfb75c6 Add twitter:card metatag to enable Twitter Cards support 2017-01-06 09:47:40 +11:00
Eugen
9f21eb6064 Merge pull request #415 from ineffyble/master
Prevent duplicate desktop notifications
2017-01-05 23:15:01 +01:00
Effy Elden
10a9ebae3b Add tag property to desktop notifications, preventing duplicates (i.e. when multiple Mastodon tabs are open) 2017-01-06 08:26:45 +11:00
Eugen Rochko
00b9ba64c9 Fixed unexpected error 2017-01-05 14:23:59 +01:00
Eugen Rochko
a1de2e332d Fix compose form bug 2017-01-05 14:18:38 +01:00
Eugen Rochko
ca7dce4a5a Fix selection resetting in compose form after unrelated data updates 2017-01-05 14:11:18 +01:00
Eugen Rochko
10e6288444 Revert to Roboto for all 2017-01-05 13:59:58 +01:00
Eugen
3354dc117b Merge pull request #406 from Gargron/revert-404-emoji
Revert "Display native emoji on browsers which support it"
2017-01-05 13:45:37 +01:00
Eugen
9e6ceb3201 Revert "Display native emoji on browsers which support it" 2017-01-05 13:45:21 +01:00
Eugen
87f76d4095 Merge pull request #403 from ticky/use-system-fonts
Use system fonts on more platforms
2017-01-05 13:37:48 +01:00
Eugen
c3c9879b5e Merge pull request #404 from mistydemeo/emoji
Display native emoji on browsers which support it
2017-01-05 13:37:29 +01:00
Eugen
df89cb5488 Merge pull request #399 from ticky/public-timeline-de-hoist
Public timeline de-hoist
2017-01-05 13:36:55 +01:00
Eugen
92638308ee Merge pull request #400 from ticky/differentiate-settings-links
Differentiate settings links
2017-01-05 13:36:13 +01:00
Eugen
26ec24fa1d Merge pull request #401 from mistydemeo/new_toot_cursor_position
Automatically position cursor when writing a reply toot
2017-01-05 13:35:14 +01:00
Misty De Meo
53b765f4b1 Bump emoji requirement to Unicode 9 2017-01-04 22:47:51 -08:00
Misty De Meo
c318e6e42e Display native emoji on browsers which support it 2017-01-04 22:23:02 -08:00
Jessica Stokes
1f3c895ffb Use system fonts on more platforms
This allows other platforms such as Windows, macOS and iOS to use their system fonts rather than downloading a copy of Roboto. It also makes the app feel a little closer to native on those platforms!
2017-01-04 20:24:27 -08:00
Misty De Meo
c100b83b98 Automatically position cursor when writing a reply toot 2017-01-04 20:04:14 -08:00
Jessica Stokes
7ac55d2674 Differentiate settings links
The "settings" links in the Getting Started section (or, if #399 were to happen, "more" menu) and compose sections are now different; the "compose" link is "Edit profile," while the one in the Getting Started section is now "Preferences."

All languages have been updated to accommodate this, based on the existing usages of these phrases in language files in the Rails part of the app!

addresses part of #384
2017-01-04 19:47:02 -08:00
Jessica Stokes
05cc5636d8 Remove hamburger from "getting started" 2017-01-04 19:30:39 -08:00
Jessica Stokes
9c493b1ea2 Replace "Public" in tab bar with "More" hamburger 2017-01-04 19:30:22 -08:00
Jessica Stokes
0c600e9db6 Move "getting started" to its own route 2017-01-04 19:30:02 -08:00
Eugen
9bb1b97d2a Merge pull request #394 from ticky/email-reblog-to-boost
"Reblog" -> "boost" in more places
2017-01-05 03:35:22 +01:00
Eugen
3b604d2786 Merge pull request #395 from ticky/hide-media-list-if-no-media
Don't render the media list when there's no media
2017-01-05 03:34:49 +01:00
Eugen
879aa9ad26 Merge pull request #397 from ticky/friendlier-unknown-errors
Friendlier unknown errors
2017-01-05 03:34:06 +01:00
Eugen
52850c51db Merge pull request #396 from ticky/fix-cmd-enter
Fix Command-enter tooting
2017-01-05 03:34:01 +01:00
Jessica Stokes
cc46c6b493 Friendlier unknown errors
Don't ask users to check the console - if they're on mobile, they probably can't anyway ;)
2017-01-04 18:31:45 -08:00
Jessica Stokes
1da73ecade Fix Command-enter tooting
metaKey is only set correctly on keyDown, not keyUp, so this swaps to using that
2017-01-04 18:29:43 -08:00
Eugen Rochko
6c28886317 Improve background jobs params and error handling 2017-01-05 03:28:21 +01:00
Eugen Rochko
251b04298e Fix undesired delivering of private toot to remote accounts that follow author 2017-01-05 03:17:23 +01:00
Eugen Rochko
98729d50c8 Make shortcode emojis work, make getting started area scrollable 2017-01-05 03:14:33 +01:00
Jessica Stokes
cbcb7e1241 Don't render the media list when there's no media
This stops the empty compose view from scrolling on 4-inch devices.
2017-01-04 18:10:45 -08:00
Jessica Stokes
bb033c1d37 "Reblog" -> "boost" in more places
A couple of places were using "reblog" rather than "boost" - this updates them to match the web UI
2017-01-04 18:00:50 -08:00
Eugen
db21724a5a Merge pull request #393 from ticky/qol-for-four-inch-phones
Improve experience on 4-inch phones
2017-01-05 02:57:13 +01:00
Jessica Stokes
aaee8c9b5d Disallow compose navbar from being shrunk 2017-01-04 17:44:41 -08:00
Jessica Stokes
312736cd1b Stop Mastodon friend from overlapping text 🐘 2017-01-04 17:33:05 -08:00
Jessica Stokes
5b75f6d0f3 Make tabs bar take up less room on 4-inch phones 2017-01-04 17:32:11 -08:00
Jessica Stokes
3807b0b171 Improve quality of life for 4-inch phones
Removes extra UI margins < 360px, and allows the tab bar to scroll. Also slightly improves horizontal scrolling behaviour on desktop.
2017-01-04 17:29:09 -08:00
Eugen Rochko
98b83aca37 Fix #391 - relative timestamps now contain an exact datetime in title 2017-01-04 15:43:28 +01:00
Eugen Rochko
2b0b7ff1b8 Fix #385 - /web now loads a page 2017-01-04 15:35:36 +01:00
Eugen Rochko
1bbcd71cd4 Fix #390 - fix redirect after sign-up (to login page instead of homepage) 2017-01-04 15:31:25 +01:00
Eugen
18f59df09e Merge pull request #389 from TrevInc/master
Reduce height of media query to hide avatar/bio
2017-01-03 19:58:34 +01:00
Anthony Bellew
febe2449bb Merge pull request #2 from TrevInc/bio-media-query
Reduce height of media query to hide avatar/bio
2017-01-03 11:55:59 -07:00
Anthony Bellew
1fcb807d91 Reduce height of media query to hide avatar/bio
An additional selector was provided to make the bio only hide on the timeline and not on the user page itself.
2017-01-03 11:55:36 -07:00
Anthony Bellew
de154dbd5d Merge pull request #1 from Gargron/master
Catchup merge
2017-01-03 11:51:35 -07:00
Eugen
e6657d7342 Merge pull request #386 from mitchhentges/file-drop-ui
On file-drag, show a border around textarea
2017-01-03 12:59:21 +01:00
Mitchell Hentges
4d300e2507 On file-drag, show a border around textarea 2017-01-03 00:43:45 -08:00
Eugen Rochko
3125dd8920 Add "back" button on column headers of public and hashtag timelines. The "back"
button takes you back to getting started page
2017-01-03 01:15:42 +01:00
Eugen Rochko
86be6d48c9 Fix accidental distribution of hidden stream entries to PuSH subscribers 2017-01-03 00:44:23 +01:00
Eugen Rochko
f79ba2de83 Fix admin UI not loading JS, make sure to strip "acct:" out of remote account's usernames when authorizing follow 2017-01-02 22:31:10 +01:00
Eugen Rochko
136e18b875 Unblocks also federate 2017-01-02 14:19:02 +01:00
Eugen Rochko
65647a2472 See #244 - Added notifications column settings to filter what's displayed
in the column and what appears as desktop notifications. Settings do not
persist yet
2017-01-02 14:09:57 +01:00
Eugen Rochko
75122e162d Fix uri expansion during remote follow 2017-01-02 12:19:02 +01:00
Eugen Rochko
c04002b340 Federate blocks with the http://mastodon.social/schema/1.0 verb namespace 2017-01-02 12:17:51 +01:00
Eugen Rochko
9f9e11ce07 Update screenshot on about page 2017-01-01 19:55:33 +01:00
Eugen Rochko
8f47f6a7ec Adding remote follow button 2017-01-01 19:54:34 +01:00
Eugen
91c5426455 Update screenshot 2016-12-31 14:54:03 +01:00
Eugen Rochko
8d44281677 Set in_reply_to_account on statuses to non-self value when possible, thus
resolving the confusion from self-chain replies ultimately linking to a
non-self status. Adjust filters
2016-12-31 14:36:25 +01:00
Eugen
777bcfc701 Add docker-compose example about rake secret task 2016-12-29 20:53:18 +01:00
Eugen Rochko
a302e56f9a Add API for retrieving favourites 2016-12-29 20:33:26 +01:00
Eugen Rochko
49834a6e7f Add API for retrieving blocked accounts 2016-12-29 20:12:32 +01:00
Eugen Rochko
8724094ed0 Support remote follow request providing URL instead of acct 2016-12-29 17:23:27 +01:00
Eugen Rochko
d7dc84439c Add ability to use remote follow function on other sites 2016-12-29 16:54:54 +01:00
Eugen Rochko
8b94d283fb Fix wrong person being notified after nested reblog call, fix favourites leaking private toots in Atom feeds 2016-12-28 13:21:12 +01:00
Eugen Rochko
e2c2fefc36 Add illustration to getting started column 2016-12-27 23:30:11 +01:00
Eugen
9aaf223ae2 Add getting started illustration 2016-12-27 23:18:56 +01:00
Eugen Rochko
eca6110fc4 Add preferences for follow request notification e-mails 2016-12-26 22:04:16 +01:00
Eugen Rochko
5418df467d Only display follow requests link in getting started window if account is locked 2016-12-26 21:55:52 +01:00
Eugen Rochko
2146ac91a0 Follow requests send e-mail notifications, but are excluded from notifications API
Better initial state for unlisted/nsfw toggles
2016-12-26 21:52:03 +01:00
Eugen Rochko
3689c119f0 Replacing follow requests in the settings area with in-UI column 2016-12-26 21:33:51 +01:00
Eugen Rochko
004382e4d0 Adding follow requests API 2016-12-26 19:30:45 +01:00
Eugen Rochko
7376af90f7 Don't show statuses to blocked users 2016-12-26 19:13:56 +01:00
Eugen Rochko
3282448878 Fix #86 - resolve layout breaking on zoom-out on accounts grid 2016-12-26 18:48:33 +01:00
Eugen Rochko
c3e9ba6a66 Fix #357 - Also make textarea blur close suggestions which make it more accessible on touch devices 2016-12-24 01:39:14 +01:00
Eugen Rochko
0a84ab43d2 Fix #367 - Lock icon instead of retweet icon on boost button on private statuses 2016-12-24 01:33:55 +01:00
Eugen Rochko
60f2da1b2f Merge pull request #369 (nice) 2016-12-24 01:28:37 +01:00
Eugen Rochko
0bc6da89d2 Hide unlisted toggle when private is active, hide nsfw toggle when no files added 2016-12-24 01:28:10 +01:00
Anthony Bellew
538d109a82 Re-add unlisted toggle to the UI 2016-12-24 01:28:02 +01:00
Eugen Rochko
6e064cf715 Fix account timelines bug 2016-12-23 01:13:16 +01:00
Eugen Rochko
26f969665d Fix alignment 2016-12-23 00:45:16 +01:00
Eugen Rochko
b191afcb5b Fix confirmation email being sent to old address when changing it 2016-12-23 00:43:03 +01:00
Eugen Rochko
de9b6e3a6a Compose form in the UI now has public/private toggle instead of public/unlisted 2016-12-23 00:38:16 +01:00
Eugen Rochko
b302b9202b Add page for authorizing/rejecting follow requests 2016-12-23 00:04:52 +01:00
Eugen Rochko
3c841c7306 Adjust wording 2016-12-22 23:24:46 +01:00
Eugen Rochko
05b13c38b5 Re-enable Webfinger for locked accounts but don't handle "follow" events
coming in via Salmon.

Currently no way to prevent remote follows, but they will only receive public
and unlisted posts
2016-12-22 23:17:57 +01:00
Eugen Rochko
f729cfc881 Private posts mentioning non-followers should not notify them, neither locally nor via Salmon 2016-12-22 23:14:24 +01:00
Eugen Rochko
9d42bff285 Hide follow/unfollow button from public page if follow already requested 2016-12-22 23:07:46 +01:00
Eugen Rochko
b891a81008 Follow call on locked account creates follow request instead
Reflect "requested" relationship in API and UI
Reflect inability of private posts to be reblogged in the UI
Disable Webfinger for locked accounts
2016-12-22 23:03:57 +01:00
Eugen Rochko
2d2154ba75 Add "locked" flag to accounts, prevent blocked users from following, force-unfollow blocked users 2016-12-22 21:34:19 +01:00
Eugen Rochko
f91b6fa9e1 Merge branch 'master' into development 2016-12-22 11:35:00 +01:00
Eugen Rochko
3caf0cfb03 Ensure that reblogs and favs always refer to the original status rather than a reblog wrapper 2016-12-22 11:34:05 +01:00
Eugen Rochko
025f7bb223 Fix notifications reducer bug 2016-12-22 11:23:30 +01:00
Eugen Rochko
80e02b90e4 Private visibility on statuses prevents non-followers from seeing those
Filters out hidden stream entries from Atom feed
Blocks now generate hidden stream entries, can be used to federate blocks
Private statuses cannot be reblogged (generates generic 422 error for now)
POST /api/v1/statuses now takes visibility=(public|unlisted|private) param instead of unlisted boolean
Statuses JSON now contains visibility=(public|unlisted|private) field
2016-12-21 20:04:13 +01:00
Eugen Rochko
6d71044c85 Don't use rack timeout in any but production environments 2016-12-21 19:10:40 +01:00
Eugen Rochko
c4bc5c8930 Federated profile bio now formatted 2016-12-21 19:10:19 +01:00
Eugen Rochko
c128fcee16 Update Rails to 5.0.1 2016-12-21 19:09:59 +01:00
Eugen Rochko
318e63cb79 Fix #250 - no text adjust 2016-12-21 00:56:13 +01:00
Eugen Rochko
c30a3d259c Improve icons 2016-12-21 00:44:42 +01:00
Eugen Rochko
587bf6820e Improved launcher icon on Android 2016-12-21 00:28:57 +01:00
Eugen Rochko
e09d3a2c66 Fix #249 - use window.location hack to let people login from sandboxed iOS homescreen 2016-12-21 00:13:13 +01:00
Eugen
806ffbab63 Merge pull request #366 from tobypinder/smushed_assets
Smush assets for quicker load times/less bandwidth used.
2016-12-20 23:34:14 +01:00
Toby Pinder
ae7a2957aa Smush assets for quicker load times/less bandwidth used. 2016-12-20 19:49:44 +00:00
Eugen Rochko
4c6809f6ab Adding some rescues to workers, e.g. don't fail removal job when status can't be found in the first place (already removed) 2016-12-19 09:31:12 +01:00
Eugen Rochko
0542773bca Make unfavouriting async to prevent timeout errors from leaving orphaned records behind 2016-12-19 09:12:29 +01:00
Eugen
6b67b55cee Merge pull request #356 from victorhck/master
added two missings spanish  translations
2016-12-18 21:22:46 +01:00
Eugen Rochko
5ae1b39ec9 Adjusting public display of statuses to look similar to logged-in UI,
fix #361 with rich OEmbed display via iframe, fix #237 by hiding sensitive
content behind a spoiler on public pages
2016-12-18 19:47:11 +01:00
Eugen Rochko
aed25932b5 Add OEmbed iframe HTML, convert emojis on public pages, increase size of attachment thumbnails 2016-12-18 15:20:39 +01:00
Eugen Rochko
6de079a5af Removing external hub completely, fix #333 fixing digit-only hashtags,
removing web app capability from non-webapp pages
2016-12-18 12:24:37 +01:00
Eugen Rochko
c4ffffbeed Add a better cropped image 2016-12-15 01:07:58 +01:00
Eugen Rochko
642e464670 Adding art by cutewitchirl, open graph tags for about page 2016-12-15 00:59:49 +01:00
Eugen Rochko
8bbdd35341 Fix responsiveness of 500 page 2016-12-15 00:49:17 +01:00
victorhck
f5c8d64b6d added to missings spanish translations 2016-12-14 18:43:00 +01:00
Eugen Rochko
4df38516e6 Fix key up listener on compose form 2016-12-14 18:38:28 +01:00
Eugen Rochko
b27066e154 Re-implemented autosuggestions component for the compose form
Fix #205, fix #156, fix #124
2016-12-14 18:21:31 +01:00
Eugen Rochko
4284093aa3 Fixed AS delete verb 2016-12-14 15:13:39 +01:00
Eugen Rochko
76ec907993 Improved admin UI 2016-12-13 13:42:10 +01:00
Eugen Rochko
668013265c Restoring old async behaviour of thread resolving as it proved to be more robust 2016-12-12 21:12:19 +01:00
Eugen Rochko
908fcf83c6 Fix timelines loading bug 2016-12-12 14:39:18 +01:00
Eugen Rochko
6d1066fe61 Adding some navigation items from #262 to the getting started screen 2016-12-12 14:27:52 +01:00
Eugen Rochko
6e7e97c849 Fix metaKey usage 2016-12-11 23:54:32 +01:00
Eugen Rochko
73c142fb94 Fix #167 - Enable cmd+enter on Mac to submit form 2016-12-11 23:39:25 +01:00
Eugen Rochko
cda297450f Fix #171 - You can now drag & drop files to be uploaded 2016-12-11 23:35:06 +01:00
Eugen Rochko
f92cb02b9b Fix #203 - try/catch around JSON parse 2016-12-11 23:18:41 +01:00
Eugen Rochko
2b22c33039 Fix #157 - labels/titles on follow/unfollow buttons 2016-12-11 23:13:54 +01:00
Eugen Rochko
6a5036ab19 Fix whitespace preservation in status text 2016-12-11 23:08:46 +01:00
Eugen Rochko
e90fcb46e3 Sensitive content federates using the "nsfw" hashtag 2016-12-11 22:49:25 +01:00
Eugen Rochko
f90133d2ad Thread resolving no longer needs to be separate from ProcessFeedService,
since that is only ever called in the background
2016-12-11 22:23:11 +01:00
Eugen Rochko
6c60757e99 Improve images in opengraph meta tags 2016-12-09 12:08:39 +01:00
Eugen Rochko
c8e0ceed56 Fix OpenGraph meta tags 2016-12-09 11:56:27 +01:00
Eugen Rochko
0c491ea928 Fix translations 2016-12-09 11:52:09 +01:00
Eugen
a5797139b2 Merge pull request #346 from stamak/declaring_uk
Declare Ukrainian
2016-12-08 18:01:32 +01:00
Stanislav Makar
d2ab41aea4 Declare Ukrainian
Closes: #345
2016-12-08 18:57:58 +02:00
Eugen
1759cf8336 Merge pull request #343 from undrskr/master
Ukrainian translation: Fix some translation errors.
2016-12-08 01:29:00 +01:00
Vladyslav
2c6c912076 Fix some translation errors. 2016-12-07 21:13:57 +02:00
Eugen
46b1f1ec63 Merge pull request #342 from undrskr/master
Ukrainian translation: Final steps
2016-12-07 19:53:09 +01:00
Vladyslav
d85c566960 Final steps for Ukrainian translation 2016-12-07 20:39:22 +02:00
Eugen Rochko
8b93f45f3d Fix paperclip config 2016-12-07 17:19:29 +01:00
Eugen Rochko
f114bc7bb7 Update Paperclip config to allow plugging in Minio instead of AWS 2016-12-07 16:59:18 +01:00
Eugen Rochko
06dd359239 Downsize attachment previews, but improve quality from 80 to 90 2016-12-07 12:09:20 +01:00
Eugen Rochko
bf5f8a2449 Fix #341 - Remove react-responsive in favour of simpler resize handler/window width 2016-12-06 19:18:37 +01:00
Eugen Rochko
2ef9f36cf2 Improve suspend account service 2016-12-06 18:32:36 +01:00
Eugen Rochko
f978b06dd1 Add suspend account functionality to admin UI 2016-12-06 18:22:59 +01:00
Eugen Rochko
f406e01fcf Add filters for suspended accounts 2016-12-06 18:03:30 +01:00
Eugen Rochko
2488162733 Adding suspend account service 2016-12-06 17:41:42 +01:00
Eugen Rochko
1357c1cb3d Add single user mode 2016-12-06 17:19:26 +01:00
Eugen Rochko
84d2371d6a Fix 2016-12-06 00:24:33 +01:00
Eugen Rochko
39cc9fde8a Add account suspension 2016-12-05 22:59:30 +01:00
Eugen Rochko
64d109dc0e Downsize originals in media attachments, small fixes 2016-12-05 17:13:44 +01:00
Eugen
a910cdd54d Merge pull request #338 from stamak/uk.yml
Add Ukrainian localization file uk.yml
2016-12-05 12:30:07 +01:00
Stanislav Makar
0e18bbe3e2 Add Ukrainian localization file uk.yml
Closes: #337
2016-12-05 07:40:45 +02:00
Eugen Rochko
b362de2232 Adding configurable e-mail blacklist 2016-12-04 19:07:02 +01:00
Eugen Rochko
41b4be699f Fix rule 2016-12-04 18:34:30 +01:00
Eugen Rochko
d5da55c6cc Fix mutating objects 2016-12-04 18:32:31 +01:00
Eugen Rochko
d4559402e4 Better responsiveness 2016-12-04 18:13:07 +01:00
Eugen Rochko
9d9f796130 Adding more to admin accounts UI 2016-12-04 18:10:40 +01:00
Eugen Rochko
d236dcded2 Fix public tags page 2016-12-04 16:56:45 +01:00
Eugen Rochko
00e9dac1d3 Automatically block outgoing notifications from silenced accounts to users
who don't follow them
2016-12-04 16:51:49 +01:00
Eugen Rochko
f763e844e8 Do not use expiring links after all 2016-12-04 13:02:43 +01:00
Eugen Rochko
24e692b0cf Set a specific S3 expiration time so that files can be cached 2016-12-04 12:58:35 +01:00
Eugen Rochko
80c44ed9c1 Do not autoplay videos, display play button instead. Use expiring links when using S3. Do not keep originals
for avatars/headers, resize avatars down to 120x120 instead of 300x300. Set cache headers on S3 stuff, also
make it private (aka only accessible via expiring links to prevent hotlinking)
2016-12-04 12:28:10 +01:00
Eugen Rochko
290ffb63cd Fix cloudfront config 2016-12-03 22:12:22 +01:00
Eugen Rochko
d3bd10dfe4 Add Cloudfront support 2016-12-03 22:08:15 +01:00
Eugen Rochko
565cd95bca Keep timelines in the UI trimmed when possible 2016-12-03 21:04:57 +01:00
Eugen Rochko
b14b5e3b44 Improve notification model 2016-12-03 20:04:19 +01:00
Eugen Rochko
5abf64d647 Add "next" pagination to public profiles 2016-12-03 19:30:13 +01:00
Eugen Rochko
ec44cff9a2 Admin accounts page lists accounts 2016-12-03 19:08:07 +01:00
Eugen Rochko
816284d739 Fix #248 - Reload all accounts when fetching from cache 2016-12-03 18:21:26 +01:00
Eugen
253970cb73 Merge pull request #335 from stamak/doorkeeper.uk.yml
Add Ukrainian localization file doorkeeper.uk.yml
2016-12-03 12:15:38 +01:00
Stanislav Makar
ea91286aaa Add Ukrainian localization file doorkeeper.uk.yml
Closes: #334
2016-12-03 09:46:26 +02:00
Eugen Rochko
165498f110 Make data migration more merciful 2016-12-02 15:09:44 +01:00
Eugen Rochko
8cfcc52876 Make all account links into permalinks (open public except on left click) 2016-12-02 15:05:50 +01:00
Eugen Rochko
470f629b06 Fix #284 - Alignment in notifications column 2016-12-02 14:52:41 +01:00
Eugen Rochko
d9232959df Fix #298 - Esc to close modal 2016-12-02 14:37:00 +01:00
Eugen Rochko
3114e55c7a Fix #323 - self-replies to appear in public timelines again 2016-12-02 14:33:20 +01:00
Eugen Rochko
e3222feddb Updated README with link to production guide 2016-12-02 14:21:19 +01:00
Eugen Rochko
58b3f4fd67 Fix #329 - avatar errors no longer prevent remote accounts from being saved
(without avatar). Also improved search position of exact matches
2016-12-02 14:14:49 +01:00
Eugen
2b2797d6a5 Merge pull request #327 from TazeTSchnitzel/clearer_unlisted
Replace “Unlisted mode” with “Do not show on public timeline”
2016-12-01 20:58:00 +01:00
Eugen
fe65acd414 Merge pull request #328 from TazeTSchnitzel/fix_crlf
Fix line endings for Portuguese locale
2016-12-01 20:57:26 +01:00
Andrea Faulds
1c1102008f Fix line endings for Portuguese locale 2016-12-01 17:27:47 +00:00
Andrea Faulds
fd01f13b3c Replace "Unlisted mode" with more explicit text 2016-12-01 17:19:58 +00:00
Eugen Rochko
1d0321fc45 Fix pt translations, improve pre-cache queries, removing will_paginate
from accounts/tags because it's a terribly inefficient way to paginate
large sets of data
2016-12-01 16:26:25 +01:00
Eugen
bdf7d8f8fd Fix opengraph preview image 2016-12-01 01:56:43 +01:00
Eugen
96a2a6523b Merge pull request #322 from unsmell/master
Devise, Doorkeeper, settings & general PT translation
2016-12-01 01:12:38 +01:00
unsmell
2b13df4a8d Create pt.jsx 2016-11-30 23:05:24 -01:00
unsmell
66e55d60e1 Create pt localisation files in /config/locales/ 2016-11-30 23:04:53 -01:00
Eugen
0768c2825f Merge pull request #320 from alexgleason/readme-nginx
Guide readers to production wiki page after Docker setup
2016-11-30 23:18:30 +01:00
Eugen Rochko
6ff93845d5 Add basic OEmbed provider API, fix #247 2016-11-30 23:01:03 +01:00
Eugen Rochko
14bd46946d Per-status control for unlisted mode, also federation for unlisted mode
Fix #233, fix #268
2016-11-30 21:34:59 +01:00
Alex Gleason
5c2ea4da7d Guide readers to production wiki page after Docker setup 2016-11-30 15:25:48 -05:00
Eugen Rochko
1b447c190e Preparing sensitive content for text 2016-11-30 16:10:19 +01:00
Eugen Rochko
a21bcac9e1 Further abstract caching for includes 2016-11-30 15:57:56 +01:00
Eugen Rochko
356d3874eb Normalize localizations, add stub for admin/accounts 2016-11-30 15:32:26 +01:00
Eugen Rochko
4a2347da41 Track successful PuSH deliveries 2016-11-30 15:24:57 +01:00
Eugen Rochko
ff21ff1489 Make User#current_sign_in_at actually track when user was last active,
by updating it at least every 24h if the user visits the site
2016-11-30 15:17:03 +01:00
Eugen
872a35011a Merge pull request #261 from alyssais/test_env
Add .env.test
2016-11-29 17:51:24 +01:00
Eugen
157fd07edc Update .env.test 2016-11-29 17:51:16 +01:00
Eugen
18da021529 Merge pull request #235 from TazeTSchnitzel/settings_contrast
Improve contrast for settings pages (fixes #173)
2016-11-29 17:50:19 +01:00
Eugen
1e99a2bb03 Fix trying to PuSH-publish updates of remote removals 2016-11-29 17:41:47 +01:00
Eugen Rochko
7e90772c92 Unify collection caching code 2016-11-29 15:49:39 +01:00
Eugen Rochko
93a90cd9c3 Delete statuses asynchronously but provide instant feedback in the API 2016-11-29 15:32:25 +01:00
Eugen Rochko
5973ca3d11 Upgrade Paperclip to 5, AWS-SDK to 2, do not generate medium/small versions of avatars 2016-11-29 14:20:15 +01:00
Eugen Rochko
cc70f28f19 Adding rack timeout of 30sec, PuSH jobs moved to push queue so they
can be processed separately
2016-11-29 02:07:14 +01:00
Eugen Rochko
c0555f2db6 Don't rate-limit PuSH endpoints 2016-11-29 00:44:11 +01:00
Eugen Rochko
4e351baf88 Fix URLs in inline-rendered XML 2016-11-29 00:26:01 +01:00
Eugen
41ef277da3 Fix URLs in ApplicationController.renderer 2016-11-28 21:21:05 +01:00
Eugen Rochko
5b076cbafb Fix delete Salmons 2016-11-28 19:44:27 +01:00
Eugen Rochko
3ba6531611 Fix setting of confirmed=true on successful confirmation 2016-11-28 19:24:49 +01:00
Eugen Rochko
50638174c8 More logging to ConfirmationWorker 2016-11-28 19:15:28 +01:00
Eugen Rochko
4eba76711b Adding backtracing to Salmon/Processing workers 2016-11-28 19:11:36 +01:00
Eugen Rochko
27fc49d745 Add simple admin overview of PuSH subscriptions 2016-11-28 18:45:13 +01:00
Eugen Rochko
7e5e33df48 Fix Pubsubhubbub::UnsubscribeService 2016-11-28 18:14:49 +01:00
Eugen Rochko
79e41fbd51 Forgot to enable hungarian translation in the frontend 2016-11-28 13:55:45 +01:00
Eugen Rochko
66ab0d0d56 Enabling portuguese and hungarian translations, which are awfully incomplete 2016-11-28 13:55:03 +01:00
Eugen
ea05fdaa57 Merge pull request #304 from unsmell/patch-1
activate portuguese translation
2016-11-28 13:51:09 +01:00
Eugen Rochko
f860d15d39 Fix order of attachments (see #269) 2016-11-28 13:49:55 +01:00
Eugen Rochko
397f7dda5d Add IRC channel to readme 2016-11-28 13:40:33 +01:00
Eugen Rochko
2d2c81765b Adding embedded PuSH server 2016-11-28 13:36:47 +01:00
unsmell
7109711b71 activate portuguese translation 2016-11-27 14:57:09 -01:00
Eugen
26287b6e7d Merge pull request #275 from kodacs/master
Hungarian translation for Mastodon.
2016-11-26 18:33:48 +01:00
Eugen
0cfb8dbd20 Merge pull request #282 from nclm/misc
Allow vertical resizing of the toot textarea
2016-11-26 18:30:03 +01:00
Eugen
a7769c2672 Merge pull request #296 from TazeTSchnitzel/purge_neo4j
Remove another lingering Neo4J item
2016-11-26 18:29:46 +01:00
Andrea Faulds
1c9c176bec Remove another lingering Neo4J item 2016-11-26 16:35:37 +00:00
Eugen Rochko
8a4913fde0 Public and hashtag timelines now exclude reblogs and replies
Fix #289 - don't download avatar unless the URL is http/https
Fix #293 - reblog/reblogged is now boost/boosted
2016-11-26 15:45:35 +01:00
Eugen Rochko
054138797f Fix #288 - Strip first @ from search query, don't search accounts if it begins with # 2016-11-26 15:32:29 +01:00
Eugen Rochko
6c82dfcf5f Fix CDN_HOST variable requirement 2016-11-26 15:27:05 +01:00
Eugen Rochko
950312bada Fix #278 - Use mb_chars.downcase on hashtags 2016-11-26 15:24:14 +01:00
Eugen Rochko
2cb3dc5e5a Update hub URL and re-subscribe if hub URL changes 2016-11-26 15:18:21 +01:00
Eugen
4986c727d9 Merge pull request #280 from TazeTSchnitzel/purge_neo4j
Remove remnants of Neo4J
2016-11-25 19:14:13 +01:00
Eugen Rochko
dda9ac9222 Fix reset date format when rate limited 2016-11-25 18:20:47 +01:00
nicolas
9f731b6a27 Allow vertical resizing of the toot textarea 2016-11-25 18:17:47 +01:00
nicolas
9f9680412f Merge pull request #6 from Gargron/master
Merge
2016-11-25 18:14:42 +01:00
Andrea Faulds
b107a4820a Remove remnants of Neo4J 2016-11-25 16:15:46 +00:00
Eugen Rochko
4495baf451 X-RateLimit-Reset formatted with iso8601 2016-11-25 15:21:22 +01:00
kodacs
b503a5e05e #275 fix missing i18n item 2016-11-25 14:56:28 +01:00
Eugen Rochko
898ab66e2e Fix checkbox spacing 2016-11-25 14:02:12 +01:00
nicolas
c1aab76da4 Checkboxes label adjustement
When the label of a checkbox is more than one line long, avoid unalignment.
2016-11-25 14:02:12 +01:00
kodacs
7bd559158b fixing yamls for #275 2016-11-25 13:57:37 +01:00
Eugen
50bec5b999 Merge pull request #276 from nclm/french-translation
French translation update
2016-11-25 13:41:49 +01:00
kodacs
6586408c69 fix #275 2016-11-25 13:35:08 +01:00
nicolas
959e4b8198 Merge branch 'master' into french-translation 2016-11-25 13:33:00 +01:00
nicolas
effdb42f4c Update fr.jsx 2016-11-25 13:31:46 +01:00
nicolas
9bb885805c Update simple_form.fr.yml 2016-11-25 13:30:52 +01:00
Eugen Rochko
8a3745a4df Remove stale entries from cache results 2016-11-25 13:25:40 +01:00
Eugen Rochko
1ff0d5aea6 Normalized locale files 2016-11-25 13:15:07 +01:00
Eugen
1c9e21a507 Merge pull request #273 from nclm/french-translation
French localisation update
2016-11-25 13:14:30 +01:00
Eugen Rochko
71401659b8 Fix #65 - Options to block notifications from people you don't follow/who don't follow you 2016-11-25 13:13:16 +01:00
kodacs
eb3a12bba6 Server side HU translation + Toot fix. 2016-11-25 12:43:23 +01:00
Eugen Rochko
e3ed212b85 Filter statuses that mention blocked users 2016-11-25 12:35:52 +01:00
kodacs
7d6c461739 Hungarian translation 2016-11-25 09:43:19 +01:00
nicolas
d0122045f4 Update simple_form.fr.yml 2016-11-25 09:25:31 +01:00
nicolas
4cc6684881 Update fr.yml 2016-11-25 09:25:29 +01:00
nicolas
03d22fabb7 Update fr.jsx 2016-11-25 09:25:28 +01:00
nicolas
9af6c52a41 Merge pull request #3 from Gargron/master
Merge
2016-11-25 08:53:34 +01:00
Eugen Rochko
543d06971e Fix bad reference 2016-11-24 23:56:37 +01:00
Eugen Rochko
34e18eb251 Fix forgotten reference 2016-11-24 23:49:45 +01:00
Eugen Rochko
8efa081f21 Remove Neo4J 2016-11-24 23:46:27 +01:00
Eugen Rochko
fca183968e Center header in account column, hide media in "muted" status components (notifications) 2016-11-24 23:09:53 +01:00
Eugen
c0a223b480 Merge pull request #265 from nclm/french-translation
Forgot to add one `fr` to make the French locale (hopefully) work
2016-11-24 20:31:35 +01:00
nicolas
60577f4c6e Merge branch 'master' into french-translation 2016-11-24 20:13:30 +01:00
nicolas
5e33445c5f Update index.jsx
Forgot to add `fr` here.
2016-11-24 20:12:16 +01:00
Eugen Rochko
8e34bed7cc Mini Profiler not working well, remove it 2016-11-24 19:59:11 +01:00
Alyssa Ross
75d0903317 Add .env.test
It took me ages to get Mastodon set up with the tests passing because
the environment variables I needed to define weren't documented and I
had to work them out one at a time.

This change adds a .env.test file, and makes it so it isn't ignored by
git. I think it makes sense for the .env.test file to be in git, since
there's nothing secret in it, but other approaches would be to have a
.env.test.sample (like the corresponding one for production), or to set
these values in a test helper, or to adapt the tests to work with
environments other than this one. I'd be happy to make these changes if
that would be preferred.

Being able to get set up and run the tests is a pretty important part of
being able to contribute to Mastodon (or your test coverage with
suffer!), so having some sort of solution like this one is vital.
2016-11-24 17:48:25 +00:00
Eugen Rochko
b6ee2fb1c6 Add some OpenGraph tags to public statuses 2016-11-24 18:28:10 +01:00
Eugen Rochko
4f1d863615 Only distribute statuses to followers who signed in in the last 2 weeks, add rake task for clearing feeds of inactive users 2016-11-24 18:17:58 +01:00
Eugen
7baca3fe4d Merge pull request #242 from TazeTSchnitzel/media_uri_obfuscation
Rename media to avoid exposing filename (fixes #207)
2016-11-24 16:27:32 +01:00
Alyssa Ross
cefef2c571 Extract filename obfuscation into module 2016-11-24 00:30:58 +00:00
Eugen Rochko
cbc50016eb Follow/unfollow button outside of dropdown, also make favs/reblogs update indicator instantly and
then adjust to failure later if the request fails
2016-11-23 23:34:12 +01:00
Eugen Rochko
7cee27f517 Fix unfollows not clearing reblogs, fix blocks not clearing reblogs and notifications,
skip ActionCable for follow/unfollow/block events, instead clear UI from
blocked account's posts instantly if block request succeeds. Add forgotten
i18n for sensitive content
2016-11-23 22:57:57 +01:00
Andrea Faulds
7161f91313 Rename media to avoid exposing filename (fixes #207) 2016-11-23 21:03:03 +00:00
Eugen Rochko
3373ae02de Merge branch 'development' 2016-11-23 19:01:12 +01:00
Eugen Rochko
79a0135869 Cache accounts/:id/statuses and single statuses too 2016-11-23 19:00:43 +01:00
Eugen Rochko
2112a81e86 Adding content sensitivity toggle, spoilers for media 2016-11-23 18:53:23 +01:00
Andrea Faulds
8e936b03d5 Improve contrast for settings pages (fixes #173) 2016-11-23 17:37:10 +00:00
Eugen
d74cef45aa Merge pull request #227 from nclm/french-translation
Missing lines in the French translation
2016-11-23 17:18:27 +01:00
nicolas
e8725d2d98 Rename ‘Publier’ to ‘Pouet’ in French locale.
🎺 https://mastodon.social/users/Pierrec/updates/100589
2016-11-23 15:53:49 +01:00
nicolas
23677bc51e Merge branch 'master' into french-translation 2016-11-23 15:43:27 +01:00
nicolas
1e95fbb10b Update doorkeeper.fr.yml
e
2016-11-23 14:47:41 +01:00
nicolas
94f96a6e85 Update doorkeeper.fr.yml 2016-11-23 14:45:31 +01:00
Eugen Rochko
5434ad3002 Add content spoilers for media in sensitive-content statuses 2016-11-23 11:23:32 +01:00
Eugen Rochko
0603971894 Adding sensitive marker to statuses in API 2016-11-23 10:46:48 +01:00
Andrea Faulds
82191b3383 Explain application secrets (#217) 2016-11-23 09:30:53 +01:00
Eugen Rochko
4bdb6a0eaf Rename "publish" to "toot" in english locale, fix lightbox showing old image
before loading new one, cache notifications API, fix missing follow button
on public profiles
2016-11-23 09:20:34 +01:00
Eugen Rochko
9b53c7d353 Fix #168 - Turn off e-mail notifications by default 2016-11-23 08:39:04 +01:00
Eugen Rochko
cf912e01fd Implement includes caching for timelines APIs 2016-11-23 08:34:35 +01:00
Eugen Rochko
5c78547198 More query optimizations 2016-11-22 23:18:54 +01:00
Eugen Rochko
fc90d38893 Moving some counter queries out of subqueries in the API 2016-11-22 22:59:54 +01:00
Eugen Rochko
4b5b953d42 Upgrade to latest Rails, which apparently improves ActionCable performance 2016-11-22 22:26:44 +01:00
Eugen Rochko
45c7ee39b3 Remove unneeded indices, improve error handling in background workers, don't needlessly reload reblogged status, send Devise e-mails asynchronously 2016-11-22 17:32:51 +01:00
nicolas
f5dd152e1a Merge pull request #2 from Gargron/master
Merge
2016-11-22 17:26:17 +01:00
Eugen Rochko
95db6cbe28 Merge branch 'nclm-french-translation' 2016-11-22 11:35:15 +01:00
Eugen Rochko
4a422650bb Fix broken locales 2016-11-22 11:34:36 +01:00
nicolas
c7031fd535 Update fr.jsx 2016-11-22 10:49:50 +01:00
nicolas
82cb34916a Update fr.yml 2016-11-22 10:39:15 +01:00
nicolas
ba4c03de71 Update simple_form.fr.yml
silenced mode + edits
2016-11-22 10:32:43 +01:00
nicolas
89dab7c534 Merging code updates (Unlisted Mode) 2016-11-22 10:25:17 +01:00
nicolas
f9bd3d8808 Adding French locale to mastodon.jsx 2016-11-22 10:21:56 +01:00
nicolas
115ce90578 Adding French locale to settings_helper.rb 2016-11-22 10:20:28 +01:00
Eugen Rochko
1788164352 Local accounts can control "silenced" attribute which removes them from public timeline 2016-11-21 23:06:41 +01:00
nicolas
64cfbbcc55 Adding French locale 2016-11-21 22:46:59 +01:00
nicolas
46d9076e99 Importing doorkeeper.fr.yml
From https://github.com/doorkeeper-gem/doorkeeper-i18n/blob/master/rails/locales/fr.yml
2016-11-21 22:39:20 +01:00
nicolas
12e9f789ab Importing devise.fr.yml
From https://gist.github.com/qsypoq/4cf3b858ff8242ffb013
2016-11-21 22:34:50 +01:00
Eugen Rochko
7abbb02824 Fix #163 - No need for Safari chrome on iOS 2016-11-21 22:33:44 +01:00
nicolas
835d5483fe First version for fr.jsx 2016-11-21 22:06:05 +01:00
Eugen Rochko
7944ed6fe5 Adding option to specify asset server, filter followers query by local accounts
during Fan Out On Write to load less stuff into memory
2016-11-21 22:04:10 +01:00
nicolas
6b6243a186 Create fr.jsx 2016-11-21 21:51:34 +01:00
nicolas
a3afea7b9d First version for simple_form.fr.yml 2016-11-21 21:30:13 +01:00
nicolas
fc87243c39 First version for fr.yml 2016-11-21 21:25:27 +01:00
nicolas
05823c325c Create devise.fr.yml 2016-11-21 20:59:41 +01:00
nicolas
753115ff57 Create doorkeeper.fr.yml 2016-11-21 20:59:13 +01:00
nicolas
8504110d45 Create simple_form.fr.yml 2016-11-21 20:58:43 +01:00
nicolas
e9980a9b8b Create fr.yml 2016-11-21 20:57:28 +01:00
Eugen Rochko
627a85f4e4 Better error message in doorkeeper json response 2016-11-21 16:19:35 +01:00
Eugen Rochko
f88ca4a206 Performance improvement for notifications API 2016-11-21 16:10:42 +01:00
Eugen Rochko
52119104b9 Remove some n+1 queries from notifications API 2016-11-21 15:16:04 +01:00
Eugen Rochko
e8c27767aa Remove orphaned notifications, add scopes param to app create API 2016-11-21 14:59:13 +01:00
Eugen Rochko
954f344cf7 Merge branch 'victorhck-master' 2016-11-21 11:05:33 +01:00
Eugen Rochko
db58235930 Fixes for the Spanish translation 2016-11-21 11:05:11 +01:00
Eugen Rochko
e3665c1d67 Try to fix for browsers that don't support notifications 2016-11-21 10:59:59 +01:00
Eugen Rochko
c41dc5e8e9 Icon for desktop notifications 2016-11-21 10:55:49 +01:00
Eugen Rochko
d32e0364f9 Switch to compose route when replying and compose is not mounted 2016-11-21 10:52:11 +01:00
Eugen Rochko
93577f74e7 Use old rules for mention notifications as for mentions timeline 2016-11-21 10:37:34 +01:00
Eugen Rochko
fb48cc3b74 Desktop notifications 2016-11-21 10:24:50 +01:00
Eugen Rochko
e616ffc5d6 Fix responsive layout breakpoint, replace mentions column with notifications 2016-11-21 10:03:55 +01:00
Eugen Rochko
29b12f9e0a Call NotifyService about remote reblogs 2016-11-21 09:56:01 +01:00
Eugen Rochko
38dd85daab Adding notifications column 2016-11-20 19:39:58 +01:00
Eugen Rochko
da2ef4d676 Adding unified streamable notifications 2016-11-20 19:39:58 +01:00
caesarologia
3838e6836d Portuguese translation (front-end) 2016-11-20 11:40:26 +01:00
Victorhck
c12125e6b5 improvements in text made by Ekaitz 2016-11-20 00:52:06 +01:00
Victorhck
0b48973733 fix typos, and suggestions made by Ekaitz 2016-11-20 00:50:45 +01:00
Eugen Rochko
8977fde8ed Fix up Spanish translations 2016-11-19 22:05:26 +01:00
victorhck
daf90377bd Merge branch 'master' of https://github.com/victorhck/mastodon 2016-11-19 18:44:58 +01:00
victorhck
065f372bd1 Merge remote-tracking branch 'upstream/master' 2016-11-19 18:42:37 +01:00
Victorhck
d68750d7dc Finished spanish translation config files 2016-11-19 18:40:03 +01:00
Eugen
93e47c7135 Fix wrong language declaration 2016-11-19 18:31:36 +01:00
Eugen
5cda7f6bbb Fix wrong language declaration 2016-11-19 18:31:21 +01:00
victorhck
a6ed09441c finished spanish translation config files 2016-11-19 17:45:45 +01:00
Eugen Rochko
51dc725794 Graphic emoji in usernames in the "reblogged" string above status 2016-11-19 00:28:42 +01:00
Eugen Rochko
39533190c2 Display human names of locales in dropdown 2016-11-19 00:19:57 +01:00
Eugen Rochko
c7a1b78536 Activating Spanish i18n 2016-11-19 00:10:47 +01:00
Eugen Rochko
ac2403fb24 Add more Spanish i18n 2016-11-19 00:01:02 +01:00
Victorhck
367b05d733 Spanish translation for devise.es.yml 2016-11-18 23:57:03 +01:00
Eugen Rochko
0293a7dd49 Updating some npm packages 2016-11-18 23:55:44 +01:00
Eugen Rochko
7dea6a23f7 Move Salmon processing to background as well as PuSH 2016-11-18 23:24:57 +01:00
Eugen Rochko
2c58e7e06a Catch validation errors in ProcessFeedService so that one failing entry wouldn't stop others from going through 2016-11-18 23:19:38 +01:00
Eugen Rochko
e4f56fa942 Don't create attachments if remote file cannot be fetched 2016-11-18 23:16:34 +01:00
Eugen Rochko
882de42bab Validate presence of text even for remote statuses 2016-11-18 23:14:41 +01:00
Eugen Rochko
7b7bf834e9 Fix OAuth authorization page and add a spec for it 2016-11-18 23:10:44 +01:00
Victorhck
a05fe70c24 finished spanish translation for es.yml 2016-11-18 23:04:26 +01:00
victorhck
084668c170 started sp translations config locales 2016-11-18 20:39:40 +01:00
victorhck
2f1b6d4f41 fixed typos 2016-11-18 20:07:48 +01:00
Victorhck
1fd2ac774f Added spanish translation (#150) 2016-11-18 19:12:44 +01:00
Eugen
3794e4e307 Fix syntax error 2016-11-18 19:10:38 +01:00
victorhck
9d9bb1d728 doing spanish translation 2016-11-18 19:06:33 +01:00
victorhck
305d0375ab added spanish translation 2016-11-18 18:52:48 +01:00
Eugen Rochko
85d1b74ac3 Adding german localization 2016-11-18 15:53:46 +01:00
Eugen
c1be1ac7c6 Merge pull request #149 from larkinscott/master
Create .codeclimate.yml
2016-11-17 20:42:52 +01:00
Scott Larkin
e177ff305a Create .codeclimate.yml
Created a .codeclimate.yml configuration file that will work with your existing configurations for ESLint and Rubocop.
2016-11-17 12:40:23 -05:00
Eugen Rochko
a6e4f754fc Add an Intl polyfill 2016-11-17 16:34:36 +01:00
Eugen Rochko
7ac574d9a9 Fix #148 - Devise mailer fixed, test spec added so it won't slip past again 2016-11-17 12:29:11 +01:00
Eugen Rochko
b2e504616a Remove broken test 2016-11-16 19:11:30 +01:00
Eugen Rochko
116ab27e08 i18n for devise mailer too 2016-11-16 18:25:21 +01:00
Eugen Rochko
2c766bd4b4 Add user locale setting 2016-11-16 17:56:31 +01:00
Eugen Rochko
01e43c3e57 Adding react-intl i18n to the frontend. No translations yet 2016-11-16 17:20:52 +01:00
Eugen Rochko
546c4718e7 Localizations for most server-side strings 2016-11-16 00:55:33 +01:00
Eugen Rochko
3ce6ac0ce2 Adding some localizations 2016-11-15 23:02:57 +01:00
Eugen Rochko
a4313224d9 Improve how public status pages look slightly 2016-11-15 18:45:54 +01:00
Eugen Rochko
489bd99803 Support emojis in display name, bio 2016-11-15 18:38:57 +01:00
Eugen Rochko
4f07fb1f0a Fix #144 - Filter statuses from blocked users out of ancestors/descendants results 2016-11-15 17:33:41 +01:00
Eugen Rochko
fdc17bea58 Fix rubocop issues, introduce usage of frozen literal to improve performance 2016-11-15 16:56:29 +01:00
Eugen Rochko
a91c3ef6ce Delegate processing of incoming PuSH data to background workers 2016-11-15 15:43:33 +01:00
Eugen Rochko
cea28e0c1d Fix wrong link header on followers API, wrong link in tabs component, order
account results
2016-11-14 01:19:25 +01:00
Eugen Rochko
f8f15e5697 Fix #63 - Filter mentions that reply to blocked users 2016-11-13 21:11:45 +01:00
Eugen Rochko
bcfa49aea7 Fix another mistake with infinite user list for following 2016-11-13 21:02:10 +01:00
Eugen Rochko
4286d49ade Fix another mistake 2016-11-13 20:55:24 +01:00
Eugen Rochko
44f236e889 Fix infinite scrolling mistake 2016-11-13 20:52:11 +01:00
Eugen Rochko
dbfe1e4be6 Infinite scroll for followers/following lists 2016-11-13 20:42:54 +01:00
Eugen Rochko
49b7896953 Fix stale reference 2016-11-13 19:44:19 +01:00
Eugen Rochko
3f54fba0d3 Use privacy policy from Discourse 2016-11-13 19:35:45 +01:00
Eugen Rochko
7ce4670164 Force utf-8 encoding when processing XML 2016-11-13 19:12:40 +01:00
Eugen Rochko
50d3083cbd Making upload button into a smaller icon button 2016-11-13 19:08:52 +01:00
Eugen Rochko
d42ed78aa4 Fix #110 - Make web UI use browser history, change links in e-mails to link to that 2016-11-13 14:02:34 +01:00
Eugen Rochko
c4eb63c1d4 Debounce autosuggestions requests 2016-11-13 13:13:36 +01:00
Eugen Rochko
f0bdfadab7 Search component 2016-11-13 13:04:18 +01:00
Eugen Rochko
8152584cf5 Fix #142 - Escape ILIKE special characters from Account.find_remote 2016-11-13 11:27:13 +01:00
Eugen Rochko
20aa777c58 Weigh username matches higher than domain matches in account search 2016-11-12 14:56:40 +01:00
Eugen Rochko
afded319d2 Add limit to search results 2016-11-12 14:49:28 +01:00
Eugen Rochko
09218d4c01 Use full-text search for autosuggestions 2016-11-12 14:36:10 +01:00
Eugen Rochko
cd765f26a9 Upgrade ruby to 2.3.1 2016-11-12 01:55:33 +01:00
Eugen Rochko
ff229aa978 Fix #141 - Add "Server: Mastodon" header to responses 2016-11-10 23:34:33 +01:00
Eugen Rochko
1c17b932fe Enable emojis in reply indicator as well 2016-11-10 23:30:42 +01:00
Eugen Rochko
82fd74d101 Fix modals sizing and add animation to it, fix #140 by only making the text of
status clickable (this also fixes multiple dropdown being openable at the same
time)
2016-11-10 23:21:24 +01:00
Eugen Rochko
0320ea4b85 Fix public timeline query 2016-11-10 21:58:22 +01:00
Eugen Rochko
36921be9aa Fix #131 - Make active favourite buttons yellow 2016-11-10 01:32:32 +01:00
Eugen Rochko
ca3b364aea Fix #122 - Add microformats-2 classes to mention links 2016-11-10 01:19:33 +01:00
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
2389 changed files with 16009 additions and 3735 deletions

View File

@@ -1,4 +1,7 @@
{
"presets": ["es2015", "react"],
"plugins": ["transform-object-rest-spread"]
"plugins": [
"transform-decorators-legacy",
"transform-object-rest-spread"
]
}

18
.codeclimate.yml Normal file
View File

@@ -0,0 +1,18 @@
engines:
duplication:
enabled: true
config:
languages:
- ruby
- javascript
rubocop:
enabled: true
eslint:
enabled: true
ratings:
paths:
- "**.rb"
- "**.js"
exclude_paths:
- spec/
- vendor/asset

View File

@@ -6,20 +6,38 @@ DB_USER=postgres
DB_NAME=postgres
DB_PASS=
DB_PORT=5432
NEO4J_HOST=neo4j
NEO4J_PORT=7474
# Federation
LOCAL_DOMAIN=example.com
LOCAL_HTTPS=true
# Application secrets
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
PAPERCLIP_SECRET=
SECRET_KEY_BASE=
# Registrations
# Single user mode will disable registrations and redirect frontpage to the first profile
# SINGLE_USER_MODE=true
# Prevent registrations with following e-mail domains
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
# E-mail configuration
SMTP_SERVER=smtp.mailgun.org
SMTP_PORT=587
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=notifications@example.com
# Optional asset host for multi-server setups
# CDN_HOST=assets.example.com
# S3 (optional)
# S3_ENABLED=true
# S3_BUCKET=
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# S3_REGION=
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
# S3_CLOUDFRONT_HOST=

3
.env.test Normal file
View File

@@ -0,0 +1,3 @@
# Federation
LOCAL_DOMAIN=cb6e6126.ngrok.io
LOCAL_HTTPS=true

View File

@@ -15,7 +15,37 @@
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
},
"arrowFunctions": true,
"jsx": true,
"destructuring": true,
"modules": true,
"spread": true
}
},
"rules": {
"no-cond-assign": 2,
"no-console": 1,
"no-irregular-whitespace": 2,
"no-unreachable": 2,
"valid-typeof": 2,
"consistent-return": 2,
"dot-notation": 2,
"eqeqeq": 2,
"no-fallthrough": 2,
"no-unused-expressions": 2,
"strict": 0,
"no-catch-shadow": 2,
"indent": [1, 2],
"brace-style": 1,
"comma-spacing": [1, {"before": false, "after": true}],
"comma-style": [1, "last"],
"no-mixed-spaces-and-tabs": 1,
"no-nested-ternary": 1,
"no-trailing-spaces": 1,
"react/wrap-multilines": 2,
"react/self-closing-comp": 2,
"react/prop-types": 2,
"react/no-multi-comp": 0
}
}

5
.gitignore vendored
View File

@@ -19,6 +19,9 @@ coverage
public/system
public/assets
.env
.env.*
.env.production
node_modules/
neo4j/
# Ignore Vagrant files
.vagrant/

View File

@@ -1,14 +1,90 @@
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/AbcSize:
Max: 100
Metrics/BlockNesting:
Max: 3
Metrics/ClassLength:
CountComments: false
Max: 200
Metrics/CyclomaticComplexity:
Max: 15
Metrics/MethodLength:
Max: 55
Metrics/ModuleLength:
CountComments: false
Max: 200
Metrics/PerceivedComplexity:
Max: 10
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: true
Style/FrozenStringLiteralComment:
Enabled: true
Style/SpaceInsideHashLiteralBraces:
EnforcedStyle: space
Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: 'comma'
Style/RegexpLiteral:
Enabled: false
Style/Lambda:
Enabled: false
Rails/HasAndBelongsToMany:
Enabled: false
AllCops:
TargetRubyVersion: 2.3
Exclude:
- 'spec/**/*'
- 'db/**/*'
- 'app/views/**/*'
- 'config/**/*'
- 'bin/*'
- 'Rakefile'
- 'node_modules/**/*'
- 'Vagrantfile'

View File

@@ -1 +1 @@
ruby-2.2.4
2.3.1

View File

@@ -11,14 +11,12 @@ env:
- LOCAL_DOMAIN=cb6e6126.ngrok.io
- LOCAL_HTTPS=true
- RAILS_ENV=test
- NEO4J_HOST=localhost
- NEO4J_PORT=7575
addons:
postgresql: 9.4
rvm:
- 2.2.4
- 2.3.1
services:
- redis-server

View File

@@ -1,4 +1,4 @@
FROM ruby:2.2.4
FROM ruby:2.3.1
ENV RAILS_ENV=production

View File

@@ -1,17 +0,0 @@
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

24
Gemfile
View File

@@ -1,10 +1,12 @@
source 'https://rubygems.org'
# frozen_string_literal: true
gem 'rails', '5.0.0.1'
source 'https://rubygems.org'
ruby '2.3.1'
gem 'rails', '~> 5.0.1.0'
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 +14,14 @@ gem 'puma'
gem 'hamlit-rails'
gem 'pg'
gem 'pghero'
gem 'dotenv-rails'
gem 'font-awesome-rails'
gem 'best_in_place', '~> 3.0.1'
gem 'paperclip', '~> 4.3'
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder'
gem 'aws-sdk', '>= 2.0'
gem 'http'
gem 'httplog'
@@ -37,18 +42,23 @@ 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 'rails-settings-cached'
gem 'pg_search'
gem 'simple-navigation'
gem 'statsd-instrument'
gem 'react-rails'
gem 'browserify-rails'
gem 'autoprefixer-rails'
group :development, :test do
gem 'rspec-rails'
gem 'pry-rails'
gem 'fuubar'
gem 'fabrication'
gem 'i18n-tasks', '~> 0.9.6'
end
group :test do
@@ -69,4 +79,6 @@ end
group :production do
gem 'rails_12factor'
gem 'lograge'
gem 'redis-rails'
gem 'rack-timeout-puma'
end

View File

@@ -1,54 +1,68 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (5.0.0.1)
actionpack (= 5.0.0.1)
actioncable (5.0.1)
actionpack (= 5.0.1)
nio4r (~> 1.2)
websocket-driver (~> 0.6.1)
actionmailer (5.0.0.1)
actionpack (= 5.0.0.1)
actionview (= 5.0.0.1)
activejob (= 5.0.0.1)
actionmailer (5.0.1)
actionpack (= 5.0.1)
actionview (= 5.0.1)
activejob (= 5.0.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.0.0.1)
actionview (= 5.0.0.1)
activesupport (= 5.0.0.1)
actionpack (5.0.1)
actionview (= 5.0.1)
activesupport (= 5.0.1)
rack (~> 2.0)
rack-test (~> 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.0.0.1)
activesupport (= 5.0.0.1)
actionview (5.0.1)
activesupport (= 5.0.1)
builder (~> 3.1)
erubis (~> 2.7.0)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
active_record_query_trace (1.5.3)
activejob (5.0.0.1)
activesupport (= 5.0.0.1)
activejob (5.0.1)
activesupport (= 5.0.1)
globalid (>= 0.3.6)
activemodel (5.0.0.1)
activesupport (= 5.0.0.1)
activerecord (5.0.0.1)
activemodel (= 5.0.0.1)
activesupport (= 5.0.0.1)
activemodel (5.0.1)
activesupport (= 5.0.1)
activerecord (5.0.1)
activemodel (= 5.0.1)
activesupport (= 5.0.1)
arel (~> 7.0)
activesupport (5.0.0.1)
activesupport (5.0.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (~> 0.7)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.4.0)
arel (7.1.1)
addressable (2.5.0)
public_suffix (~> 2.0, >= 2.0.2)
arel (7.1.4)
ast (2.3.0)
autoprefixer-rails (6.5.0.2)
execjs
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (2.6.28)
aws-sdk-resources (= 2.6.28)
aws-sdk-core (2.6.28)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-resources (2.6.28)
aws-sdk-core (= 2.6.28)
aws-sigv4 (1.0.0)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.11)
best_in_place (3.0.3)
actionpack (>= 3.2)
railties (>= 3.2)
better_errors (2.1.1)
coderay (>= 1.0.0)
erubis (>= 2.6.6)
@@ -62,8 +76,7 @@ GEM
bullet (5.3.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
climate_control (0.0.3)
activesupport (>= 3.0)
climate_control (0.1.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.1)
@@ -75,8 +88,8 @@ GEM
execjs
coffee-script-source (1.10.0)
colorize (0.8.1)
concurrent-ruby (1.0.2)
connection_pool (2.2.0)
concurrent-ruby (1.0.4)
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
debug_inspector (0.0.2)
@@ -88,7 +101,7 @@ GEM
warden (~> 1.2.3)
diff-lcs (1.2.5)
docile (1.1.5)
domain_name (0.5.20160826)
domain_name (0.5.20161129)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (4.2.0)
railties (>= 4.2)
@@ -96,8 +109,11 @@ GEM
dotenv-rails (2.1.1)
dotenv (= 2.1.1)
railties (>= 4.0, < 5.1)
easy_translate (0.5.0)
json
thread
thread_safe
erubis (2.7.0)
excon (0.53.0)
execjs (2.7.0)
fabrication (2.15.2)
fast_blank (1.0.0)
@@ -108,7 +124,7 @@ GEM
ruby-progressbar (~> 1.4)
globalid (0.3.7)
activesupport (>= 4.1.0)
goldfinger (1.1.0)
goldfinger (1.1.2)
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
@@ -122,9 +138,10 @@ GEM
hamlit (>= 1.2.0)
railties (>= 4.0.1)
hashdiff (0.3.0)
highline (1.7.8)
hiredis (0.6.1)
htmlentities (4.3.4)
http (2.0.3)
http (2.1.0)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (~> 1.0.1)
@@ -136,9 +153,20 @@ GEM
httplog (0.3.2)
colorize
i18n (0.7.0)
i18n-tasks (0.9.6)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
easy_translate (>= 0.5.0)
erubis
highline (>= 1.7.3)
i18n
parser (>= 2.2.3.0)
term-ansicolor (>= 1.3.2)
terminal-table (>= 1.5.1)
jbuilder (2.6.0)
activesupport (>= 3.0.0, < 5.1)
multi_json (~> 1.2)
jmespath (1.3.1)
jquery-rails (4.1.1)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
@@ -146,11 +174,8 @@ GEM
json (1.8.3)
launchy (2.4.3)
addressable (~> 2.3)
ledermann-rails-settings (2.4.2)
activerecord (>= 3.1)
letter_opener (1.4.1)
launchy (~> 2.2)
libv8 (3.16.14.15)
link_header (0.0.8)
lograge (0.4.1)
actionpack (>= 4, < 5.1)
@@ -164,39 +189,37 @@ GEM
mime-types (3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521)
mimemagic (0.3.0)
mimemagic (0.3.2)
mini_portile2 (2.1.0)
minitest (5.9.1)
minitest (5.10.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.1)
mini_portile2 (~> 2.1.0)
oj (2.17.3)
orm_adapter (0.5.0)
os (0.9.6)
ostatus2 (1.0.2)
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
paperclip (4.3.7)
activemodel (>= 3.2.0)
activesupport (>= 3.2.0)
paperclip (5.1.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
cocaine (~> 0.5.5)
mime-types
mimemagic (= 0.3.0)
mimemagic (~> 0.3.0)
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
parser (2.3.1.2)
ast (~> 2.2)
pg (0.18.4)
pg_search (1.0.6)
activerecord (>= 3.1)
activesupport (>= 3.1)
arel
pghero (1.6.2)
activerecord
powerpack (0.1.1)
pry (0.10.4)
coderay (~> 1.1.0)
@@ -204,33 +227,40 @@ GEM
slop (~> 3.4)
pry-rails (0.3.4)
pry (>= 0.9.10)
public_suffix (2.0.4)
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)
rack (>= 1.0)
rails (5.0.0.1)
actioncable (= 5.0.0.1)
actionmailer (= 5.0.0.1)
actionpack (= 5.0.0.1)
actionview (= 5.0.0.1)
activejob (= 5.0.0.1)
activemodel (= 5.0.0.1)
activerecord (= 5.0.0.1)
activesupport (= 5.0.0.1)
rack-timeout (0.4.2)
rack-timeout-puma (0.0.1)
rack-timeout (~> 0.2, >= 0.2.0)
rails (5.0.1)
actioncable (= 5.0.1)
actionmailer (= 5.0.1)
actionpack (= 5.0.1)
actionview (= 5.0.1)
activejob (= 5.0.1)
activemodel (= 5.0.1)
activerecord (= 5.0.1)
activesupport (= 5.0.1)
bundler (>= 1.3.0, < 2.0)
railties (= 5.0.0.1)
railties (= 5.0.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.1)
activesupport (>= 4.2.0, < 6.0)
nokogiri (~> 1.6.0)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
rails-settings-cached (0.6.5)
rails (>= 4.2.0)
rails_12factor (0.0.3)
rails_serve_static_assets
rails_stdout_logging
@@ -238,14 +268,14 @@ GEM
rails (> 3.1)
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.0.0.1)
actionpack (= 5.0.0.1)
activesupport (= 5.0.0.1)
railties (5.0.1)
actionpack (= 5.0.1)
activesupport (= 5.0.1)
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
rainbow (2.1.0)
rake (11.3.0)
rake (12.0.0)
rdoc (4.2.2)
json (~> 1.4)
react-rails (1.8.2)
@@ -255,8 +285,23 @@ GEM
execjs
railties (>= 3.2)
tilt
redis (3.3.1)
ref (2.0.0)
redis (3.3.2)
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)
@@ -290,7 +335,6 @@ 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)
@@ -302,11 +346,13 @@ GEM
sdoc (0.4.1)
json (~> 1.7, >= 1.7.7)
rdoc (~> 4.0)
sidekiq (4.2.1)
sidekiq (4.2.7)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (~> 1.5)
rack-protection (>= 1.5.0)
redis (~> 3.2, >= 3.2.1)
simple-navigation (4.0.3)
activesupport (>= 2.3.2)
simple_form (3.2.1)
actionpack (> 4, < 5.1)
activemodel (> 4, < 5.1)
@@ -316,20 +362,24 @@ GEM
simplecov-html (~> 0.10.0)
simplecov-html (0.10.0)
slop (3.6.0)
sprockets (3.7.0)
sprockets (3.7.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.1.1)
sprockets-rails (3.2.0)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
statsd-instrument (2.1.2)
temple (0.7.7)
therubyracer (0.12.2)
libv8 (~> 3.16.14.0)
ref
thor (0.19.1)
term-ansicolor (1.4.0)
tins (~> 1.0)
terminal-table (1.7.0)
unicode-display_width (~> 1.1)
thor (0.19.4)
thread (0.2.2)
thread_safe (0.3.5)
tilt (2.0.5)
tins (1.12.0)
tzinfo (1.2.2)
thread_safe (~> 0.1)
uglifier (3.0.1)
@@ -356,6 +406,9 @@ PLATFORMS
DEPENDENCIES
active_record_query_trace
addressable
autoprefixer-rails
aws-sdk (>= 2.0)
best_in_place (~> 3.0.1)
better_errors
binding_of_caller
browserify-rails
@@ -374,40 +427,49 @@ DEPENDENCIES
htmlentities
http
httplog
i18n-tasks (~> 0.9.6)
jbuilder (~> 2.0)
jquery-rails
ledermann-rails-settings
letter_opener
link_header
lograge
neography
nokogiri
oj
ostatus2
paperclip (~> 4.3)
paperclip (~> 5.1)
paperclip-av-transcoder
pg
pg_search
pghero
pry-rails
puma
rabl
rack-attack
rails (= 5.0.0.1)
rack-cors
rack-timeout-puma
rails (~> 5.0.1.0)
rails-settings-cached
rails_12factor
rails_autolink
react-rails
redis (~> 3.2)
redis-rails
rspec-rails
rspec-sidekiq
rubocop
sass-rails (~> 5.0)
sdoc (~> 0.4.0)
sidekiq
simple-navigation
simple_form
simplecov
therubyracer
statsd-instrument
uglifier (>= 1.3.0)
webmock
will_paginate
RUBY VERSION
ruby 2.3.1p112
BUNDLED WITH
1.13.0
1.13.6

2
Procfile Normal file
View File

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

View File

@@ -1,17 +1,19 @@
Mastodon
========
[![Build Status](http://img.shields.io/travis/Gargron/goldfinger.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/Gargron/mastodon.svg)][code_climate]
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
[travis]: https://travis-ci.org/Gargron/mastodon
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
[travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/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/T2q5V65.png)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
@@ -21,12 +23,14 @@ 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
- [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)
- [List of Mastodon instances](https://github.com/tootsuite/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/tootsuite/mastodon/wiki/API)
- [How to use the API via cURL/oAuth](https://github.com/tootsuite/mastodon/wiki/Testing-with-cURL)
- [Frequently Asked Questions](https://github.com/tootsuite/mastodon/wiki/FAQ)
- [List of apps](https://github.com/tootsuite/mastodon/wiki/Apps)
## Features
@@ -49,16 +53,16 @@ If you would like, you can [support the development of this project on Patreon][
- `LOCAL_DOMAIN` should be the domain/hostname of your instance. This is **absolutely required** as it is used for generating unique IDs for everything federation-related
- `LOCAL_HTTPS` set it to `true` if HTTPS works on your website. This is used to generate canonical URLs, which is also important when generating and parsing federation-related IDs
- `HUB_URL` should be the URL of the PubsubHubbub service that your instance is going to use. By default it is the open service of Superfeedr
Consult the example configuration file, `.env.production.sample` for the full list. Among other things you need to set details for the SMTP server you are going to use.
## Requirements
- Ruby
- Node.js
- PostgreSQL
- Redis
- Neo4J (optional)
- GraphAware NodeRank
- Nginx
## Running with Docker and Docker-Compose
@@ -87,8 +91,8 @@ The container has two volumes, for the assets and for user uploads. The default
- `rake mastodon:media:clear` removes uploads that have not been attached to any status after a while, you would want to run this from a periodic cronjob
- `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
- `rake mastodon:feeds:clear_all` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
- `rake mastodon:feeds:clear` removes timelines of users who haven't signed in lately, which allows to save RAM and improve message distribution. This is required to be run periodically so that when they login again the regeneration process will trigger
Running any of these tasks via docker-compose would look like this:
@@ -110,6 +114,61 @@ And finally,
Which will re-create the updated containers, leaving databases and data as is. Depending on what files have been updated, you might need to re-run migrations and asset compilation.
### Contributing
## Deployment without Docker
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/tootsuite/mastodon/wiki/Production-guide) for examples, configuration and instructions.
## Deployment on Heroku (experimental)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. It should be noted this has limited testing and could have unpredictable results.
1. Click the above button.
2. Fill in the options requested.
* You can use a .herokuapp.com domain, which will be simple to set up, or you can use a custom domain. If you want a custom domain and HTTPS, you will need to upgrade to a paid plan (to use Heroku's SSL features), or set up [CloudFlare](https://cloudflare.com) who offer free "Flexible SSL" (note: CloudFlare have some undefined limits on WebSockets. So far, no one has reported hitting concurrent connection limits).
* You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saaved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details.
* If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests.
3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
## Development with Vagrant
A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
Install the latest version for your operating systems, and then run:
vagrant plugin install vagrant-hostsupdater
This is optional, but will update your 'hosts' file when you start the virtual machine, allowing you to access the site at http://mastodon.dev (instead of http://localhost:3000).
To create and provision a new virtual machine for Mastodon development:
git clone git@github.com:tootsuite/mastodon.git
cd mastodon
vagrant up
Running `vagrant up` for the first time will run provisioning, which will:
- Download the Ubuntu 14.04 base image, if there isn't already a copy on your machine
- Create a new VirtualBox virtual machine from that image
- Run the provisioning script (located inside the Vagrantfile), which installs the system packages, Ruby gems, and JS modules required for Mastodon
Once this has completed, the virtual machine will start a rails process. You can then access your development site at http://mastodon.dev (or at http://localhost:3000 if you haven't installed vagrants-hostupdater). Any changes you make should be reflected on the server instantly. To set environment variables, copy `.env.production.sample` to `.env.vagrant` and make changes as required.
When you are finished with your session, run `vagrant halt` to stop the VM. Next time, running `vagrant up` should boot the VM, and skip provisioning.
If you no longer need your environment, or if things have gone terribly wrong, running `vagrant destroy` will delete the virtual machine (after which, running `vagrant up` will create a new one, and run provisioning).
## 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.
**IRC channel**: #mastodon on irc.freenode.net
## 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)

109
Vagrantfile vendored Normal file
View File

@@ -0,0 +1,109 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
$provision = <<SCRIPT
cd /vagrant # This is where the host folder/repo is mounted
# Add the yarn repo + yarn repo keys
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
# Add firewall rule to redirect 80 to 3000 and save
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000
echo iptables-persistent iptables-persistent/autosave_v4 boolean true | sudo debconf-set-selections
echo iptables-persistent iptables-persistent/autosave_v6 boolean true | sudo debconf-set-selections
sudo apt-get install iptables-persistent -y
# Add packages to build and run Mastodon
sudo apt-get install \
git-core \
g++ \
libpq-dev \
libxml2-dev \
libxslt1-dev \
imagemagick \
nodejs \
redis-server \
redis-tools \
postgresql \
postgresql-contrib \
yarn \
libreadline-dev \
-y
# Install rbenv
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
cd ~/.rbenv && src/configure && make -C src
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
export PATH="$HOME/.rbenv/bin::$PATH"
eval "$(rbenv init -)"
echo "Compiling Ruby 2.3.1: warning, this takes a while!!!"
rbenv install 2.3.1
rbenv global 2.3.1
cd /vagrant
# Configure database
sudo -u postgres createuser -U postgres vagrant -s
sudo -u postgres createdb -U postgres mastodon_development
# Install gems and node modules
gem install bundler
bundle install
yarn install
# Build Mastodon
bundle exec rails db:setup
bundle exec rails assets:precompile
SCRIPT
$start = <<SCRIPT
cd /vagrant
export $(cat ".env.vagrant" | xargs)
rails s -d -b 0.0.0.0
SCRIPT
VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/trusty64"
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
vb.customize ["modifyvm", :id, "--memory", "1024"]
end
config.vm.hostname = "mastodon.dev"
# This uses the vagrant-hostsupdater plugin, and lets you
# access the development site at http://mastodon.dev.
# To install:
# $ vagrant plugin install hostsupdater
if defined?(VagrantPlugins::HostsUpdater)
config.vm.network :private_network, ip: "192.168.42.42"
config.hostsupdater.remove_on_suspend = false
end
# Otherwise, you can access the site at http://localhost:3000
config.vm.network :forwarded_port, guest: 80, host: 3000
# Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
config.vm.provision :shell, inline: $provision, privileged: false
# Start up script, runs on every 'vagrant up'
config.vm.provision :shell, inline: $start, run: 'always', privileged: false
end

91
app.json Normal file
View File

@@ -0,0 +1,91 @@
{
"name": "Mastodon",
"description": "A GNU Social-compatible microblogging server",
"repository": "https://github.com/tootsuite/mastodon",
"logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png",
"env": {
"HEROKU": {
"description": "Leave this as true",
"value": "true",
"required": true
},
"LOCAL_DOMAIN": {
"description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)",
"required": true
},
"LOCAL_HTTPS": {
"description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)",
"value": "false",
"required": true
},
"PAPERCLIP_SECRET": {
"description": "The secret key for storing media files",
"generator": "secret"
},
"SECRET_KEY_BASE": {
"description": "The secret key base",
"generator": "secret"
},
"SINGLE_USER_MODE": {
"description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)",
"value": "false",
"required": true
},
"S3_ENABLED": {
"description": "Should Mastodon use Amazon S3 for storage? This is highly recommended, as Heroku does not have persistent file storage (files will be lost).",
"value": "true",
"required": false
},
"S3_BUCKET": {
"description": "Amazon S3 Bucket",
"required": false
},
"S3_REGION": {
"description": "Amazon S3 region that the bucket is located in",
"required": false
},
"AWS_ACCESS_KEY_ID": {
"description": "Amazon S3 Access Key",
"required": false
},
"AWS_SECRET_ACCESS_KEY": {
"description": "Amazon S3 Secret Key",
"required": false
},
"SMTP_SERVER": {
"description": "Hostname for SMTP server, if you want to enable email",
"required": false
},
"SMTP_PORT": {
"description": "Port for SMTP server",
"required": false
},
"SMTP_LOGIN": {
"description": "Username for SMTP server",
"required": false
},
"SMTP_PASSWORD": {
"description": "Password for SMTP server",
"required": false
},
"SMTP_DOMAIN": {
"description": "Domain for SMTP server. Will default to instance domain if blank.",
"required": false
}
},
"buildpacks": [
{
"url": "heroku/nodejs"
},
{
"url": "heroku/ruby"
}
],
"scripts": {
"postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed"
},
"addons": [
"heroku-postgresql",
"heroku-redis"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

After

Width:  |  Height:  |  Size: 174 B

View File

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

View File

@@ -5,6 +5,11 @@ window.React = require('react');
window.ReactDOM = require('react-dom');
window.Perf = require('react-addons-perf');
if (!window.Intl) {
require('intl');
require('intl/locale-data/jsonp/en.js');
}
//= require_tree ./components
window.Mastodon = require('./components/containers/mastodon');

View File

@@ -1,9 +1,6 @@
import api from '../api'
import axios from 'axios';
import api, { getLinks } from '../api'
import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
@@ -32,33 +29,70 @@ 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 function setAccountSelf(account) {
return {
type: ACCOUNT_SET_SELF,
account: account
};
};
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 FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_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 FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_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 const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
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));
});
@@ -82,23 +116,22 @@ export function expandAccountTimeline(id) {
export function fetchAccountRequest(id) {
return {
type: ACCOUNT_FETCH_REQUEST,
id: id
id
};
};
export function fetchAccountSuccess(account, relationship) {
export function fetchAccountSuccess(account) {
return {
type: ACCOUNT_FETCH_SUCCESS,
account: account,
relationship: relationship
account
};
};
export function fetchAccountFail(id, error) {
return {
type: ACCOUNT_FETCH_FAIL,
id: id,
error: error
id,
error
};
};
@@ -129,88 +162,89 @@ export function unfollowAccount(id) {
export function followAccountRequest(id) {
return {
type: ACCOUNT_FOLLOW_REQUEST,
id: id
id
};
};
export function followAccountSuccess(relationship) {
return {
type: ACCOUNT_FOLLOW_SUCCESS,
relationship: relationship
relationship
};
};
export function followAccountFail(error) {
return {
type: ACCOUNT_FOLLOW_FAIL,
error: error
error
};
};
export function unfollowAccountRequest(id) {
return {
type: ACCOUNT_UNFOLLOW_REQUEST,
id: id
id
};
};
export function unfollowAccountSuccess(relationship) {
return {
type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship: relationship
relationship
};
};
export function unfollowAccountFail(error) {
return {
type: ACCOUNT_UNFOLLOW_FAIL,
error: error
error
};
};
export function fetchAccountTimelineRequest(id) {
return {
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
id: id
id
};
};
export function fetchAccountTimelineSuccess(id, statuses) {
export function fetchAccountTimelineSuccess(id, statuses, replace) {
return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id: id,
statuses: statuses
id,
statuses,
replace
};
};
export function fetchAccountTimelineFail(id, error) {
return {
type: ACCOUNT_TIMELINE_FETCH_FAIL,
id: id,
error: error
id,
error
};
};
export function expandAccountTimelineRequest(id) {
return {
type: ACCOUNT_TIMELINE_EXPAND_REQUEST,
id: id
id
};
};
export function expandAccountTimelineSuccess(id, statuses) {
return {
type: ACCOUNT_TIMELINE_EXPAND_SUCCESS,
id: id,
statuses: statuses
id,
statuses
};
};
export function expandAccountTimelineFail(id, error) {
return {
type: ACCOUNT_TIMELINE_EXPAND_FAIL,
id: id,
error: error
id,
error
};
};
@@ -219,7 +253,8 @@ export function blockAccount(id) {
dispatch(blockAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
dispatch(blockAccountSuccess(response.data));
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => {
dispatch(blockAccountFail(id, error));
});
@@ -241,41 +276,384 @@ export function unblockAccount(id) {
export function blockAccountRequest(id) {
return {
type: ACCOUNT_BLOCK_REQUEST,
id: id
id
};
};
export function blockAccountSuccess(relationship) {
export function blockAccountSuccess(relationship, statuses) {
return {
type: ACCOUNT_BLOCK_SUCCESS,
relationship: relationship
relationship,
statuses
};
};
export function blockAccountFail(error) {
return {
type: ACCOUNT_BLOCK_FAIL,
error: error
error
};
};
export function unblockAccountRequest(id) {
return {
type: ACCOUNT_UNBLOCK_REQUEST,
id: id
id
};
};
export function unblockAccountSuccess(relationship) {
return {
type: ACCOUNT_UNBLOCK_SUCCESS,
relationship: relationship
relationship
};
};
export function unblockAccountFail(error) {
return {
type: ACCOUNT_UNBLOCK_FAIL,
error: error
error
};
};
export function fetchFollowers(id) {
return (dispatch, getState) => {
dispatch(fetchFollowersRequest(id));
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFollowersFail(id, error));
});
};
};
export function fetchFollowersRequest(id) {
return {
type: FOLLOWERS_FETCH_REQUEST,
id
};
};
export function fetchFollowersSuccess(id, accounts, next) {
return {
type: FOLLOWERS_FETCH_SUCCESS,
id,
accounts,
next
};
};
export function fetchFollowersFail(id, error) {
return {
type: FOLLOWERS_FETCH_FAIL,
id,
error
};
};
export function expandFollowers(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'followers', id, 'next']);
if (url === null) {
return;
}
dispatch(expandFollowersRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(expandFollowersFail(id, error));
});
};
};
export function expandFollowersRequest(id) {
return {
type: FOLLOWERS_EXPAND_REQUEST,
id
};
};
export function expandFollowersSuccess(id, accounts, next) {
return {
type: FOLLOWERS_EXPAND_SUCCESS,
id,
accounts,
next
};
};
export function expandFollowersFail(id, error) {
return {
type: FOLLOWERS_EXPAND_FAIL,
id,
error
};
};
export function fetchFollowing(id) {
return (dispatch, getState) => {
dispatch(fetchFollowingRequest(id));
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFollowingFail(id, error));
});
};
};
export function fetchFollowingRequest(id) {
return {
type: FOLLOWING_FETCH_REQUEST,
id
};
};
export function fetchFollowingSuccess(id, accounts, next) {
return {
type: FOLLOWING_FETCH_SUCCESS,
id,
accounts,
next
};
};
export function fetchFollowingFail(id, error) {
return {
type: FOLLOWING_FETCH_FAIL,
id,
error
};
};
export function expandFollowing(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'following', id, 'next']);
if (url === null) {
return;
}
dispatch(expandFollowingRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(expandFollowingFail(id, error));
});
};
};
export function expandFollowingRequest(id) {
return {
type: FOLLOWING_EXPAND_REQUEST,
id
};
};
export function expandFollowingSuccess(id, accounts, next) {
return {
type: FOLLOWING_EXPAND_SUCCESS,
id,
accounts,
next
};
};
export function expandFollowingFail(id, error) {
return {
type: FOLLOWING_EXPAND_FAIL,
id,
error
};
};
export function fetchRelationships(account_ids) {
return (dispatch, getState) => {
if (account_ids.length === 0) {
return;
}
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
};
};
export function fetchRelationshipsSuccess(relationships) {
return {
type: RELATIONSHIPS_FETCH_SUCCESS,
relationships
};
};
export function fetchRelationshipsFail(error) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
error
};
};
export function fetchFollowRequests() {
return (dispatch, getState) => {
dispatch(fetchFollowRequestsRequest());
api(getState).get('/api/v1/follow_requests').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null))
}).catch(error => dispatch(fetchFollowRequestsFail(error)));
};
};
export function fetchFollowRequestsRequest() {
return {
type: FOLLOW_REQUESTS_FETCH_REQUEST
};
};
export function fetchFollowRequestsSuccess(accounts, next) {
return {
type: FOLLOW_REQUESTS_FETCH_SUCCESS,
accounts,
next
};
};
export function fetchFollowRequestsFail(error) {
return {
type: FOLLOW_REQUESTS_FETCH_FAIL,
error
};
};
export function expandFollowRequests() {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowRequestsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null))
}).catch(error => dispatch(expandFollowRequestsFail(error)));
};
};
export function expandFollowRequestsRequest() {
return {
type: FOLLOW_REQUESTS_EXPAND_REQUEST
};
};
export function expandFollowRequestsSuccess(accounts, next) {
return {
type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
accounts,
next
};
};
export function expandFollowRequestsFail(error) {
return {
type: FOLLOW_REQUESTS_EXPAND_FAIL,
error
};
};
export function authorizeFollowRequest(id) {
return (dispatch, getState) => {
dispatch(authorizeFollowRequestRequest(id));
api(getState)
.post(`/api/v1/follow_requests/${id}/authorize`)
.then(response => dispatch(authorizeFollowRequestSuccess(id)))
.catch(error => dispatch(authorizeFollowRequestFail(id, error)));
};
};
export function authorizeFollowRequestRequest(id) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
id
};
};
export function authorizeFollowRequestSuccess(id) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
id
};
};
export function authorizeFollowRequestFail(id, error) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
id,
error
};
};
export function rejectFollowRequest(id) {
return (dispatch, getState) => {
dispatch(rejectFollowRequestRequest(id));
api(getState)
.post(`/api/v1/follow_requests/${id}/reject`)
.then(response => dispatch(rejectFollowRequestSuccess(id)))
.catch(error => dispatch(rejectFollowRequestFail(id, error)));
};
};
export function rejectFollowRequestRequest(id) {
return {
type: FOLLOW_REQUEST_REJECT_REQUEST,
id
};
};
export function rejectFollowRequestSuccess(id) {
return {
type: FOLLOW_REQUEST_REJECT_SUCCESS,
id
};
};
export function rejectFollowRequestFail(id, error) {
return {
type: FOLLOW_REQUEST_REJECT_FAIL,
id,
error
};
};

View File

@@ -0,0 +1,24 @@
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export function dismissAlert(alert) {
return {
type: ALERT_DISMISS,
alert
};
};
export function clearAlert() {
return {
type: ALERT_CLEAR
};
};
export function showAlert(title, message) {
return {
type: ALERT_SHOW,
title,
message
};
};

View File

@@ -1,17 +1,31 @@
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 const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@@ -19,10 +33,16 @@ export function changeCompose(text) {
};
};
export function replyCompose(status) {
return {
type: COMPOSE_REPLY,
status: status
export function replyCompose(status, router) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_REPLY,
status: status
});
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
};
};
@@ -32,6 +52,13 @@ export function cancelReplyCompose() {
};
};
export function mentionCompose(account) {
return {
type: COMPOSE_MENTION,
account: account
};
};
export function submitCompose() {
return function (dispatch, getState) {
dispatch(submitComposeRequest());
@@ -39,9 +66,18 @@ export function submitCompose() {
api(getState).post('/api/v1/statuses', {
status: getState().getIn(['compose', 'text'], ''),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']),
visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
}).then(function (response) {
dispatch(submitComposeSuccess(response.data));
dispatch(submitComposeSuccess({ ...response.data }));
// To make the app more responsive, immediately get the status into the columns
dispatch(updateTimeline('home', { ...response.data }));
if (response.data.in_reply_to_id === null && !getState().getIn(['compose', 'private']) && !getState().getIn(['compose', 'unlisted'])) {
dispatch(updateTimeline('public', { ...response.data }));
}
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@@ -121,3 +157,77 @@ export function undoUploadCompose(media_id) {
media_id: media_id
};
};
export function clearComposeSuggestions() {
return {
type: COMPOSE_SUGGESTIONS_CLEAR
};
};
export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
api(getState).get('/api/v1/accounts/search', {
params: {
q: token,
resolve: false,
limit: 4
}
}).then(response => {
dispatch(readyComposeSuggestions(token, response.data));
});
};
};
export function readyComposeSuggestions(token, accounts) {
return {
type: COMPOSE_SUGGESTIONS_READY,
token,
accounts
};
};
export function selectComposeSuggestion(position, token, accountId) {
return (dispatch, getState) => {
const completion = getState().getIn(['accounts', accountId, 'acct']);
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position,
token,
completion
});
};
};
export function mountCompose() {
return {
type: COMPOSE_MOUNT
};
};
export function unmountCompose() {
return {
type: COMPOSE_UNMOUNT
};
};
export function changeComposeSensitivity(checked) {
return {
type: COMPOSE_SENSITIVITY_CHANGE,
checked
};
};
export function changeComposeVisibility(checked) {
return {
type: COMPOSE_VISIBILITY_CHANGE,
checked
};
};
export function changeComposeListability(checked) {
return {
type: COMPOSE_LISTABILITY_CHANGE,
checked
};
};

View File

@@ -0,0 +1,83 @@
import api, { getLinks } from '../api'
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
export function fetchFavouritedStatuses() {
return (dispatch, getState) => {
dispatch(fetchFavouritedStatusesRequest());
api(getState).get('/api/v1/favourites').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchFavouritedStatusesFail(error));
});
};
};
export function fetchFavouritedStatusesRequest() {
return {
type: FAVOURITED_STATUSES_FETCH_REQUEST
};
};
export function fetchFavouritedStatusesSuccess(statuses, next) {
return {
type: FAVOURITED_STATUSES_FETCH_SUCCESS,
statuses,
next
};
};
export function fetchFavouritedStatusesFail(error) {
return {
type: FAVOURITED_STATUSES_FETCH_FAIL,
error
};
};
export function expandFavouritedStatuses() {
return (dispatch, getState) => {
const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
if (url === null) {
return;
}
dispatch(expandFavouritedStatusesRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFavouritedStatusesFail(error));
});
};
};
export function expandFavouritedStatusesRequest() {
return {
type: FAVOURITED_STATUSES_EXPAND_REQUEST
};
};
export function expandFavouritedStatusesSuccess(statuses, next) {
return {
type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
statuses,
next
};
};
export function expandFavouritedStatusesFail(error) {
return {
type: FAVOURITED_STATUSES_EXPAND_FAIL,
error
};
};

View File

@@ -1,48 +0,0 @@
import api from '../api'
export const FOLLOW_CHANGE = 'FOLLOW_CHANGE';
export const FOLLOW_SUBMIT_REQUEST = 'FOLLOW_SUBMIT_REQUEST';
export const FOLLOW_SUBMIT_SUCCESS = 'FOLLOW_SUBMIT_SUCCESS';
export const FOLLOW_SUBMIT_FAIL = 'FOLLOW_SUBMIT_FAIL';
export function changeFollow(text) {
return {
type: FOLLOW_CHANGE,
text: text
};
};
export function submitFollow(router) {
return function (dispatch, getState) {
dispatch(submitFollowRequest());
api(getState).post('/api/v1/follows', {
uri: getState().getIn(['follow', 'text'])
}).then(function (response) {
dispatch(submitFollowSuccess(response.data));
router.push(`/accounts/${response.data.id}`);
}).catch(function (error) {
dispatch(submitFollowFail(error));
});
};
};
export function submitFollowRequest() {
return {
type: FOLLOW_SUBMIT_REQUEST
};
};
export function submitFollowSuccess(account) {
return {
type: FOLLOW_SUBMIT_SUCCESS,
account: account
};
};
export function submitFollowFail(error) {
return {
type: FOLLOW_SUBMIT_FAIL,
error: error
};
};

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

@@ -1,8 +0,0 @@
export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
export function setAccessToken(token) {
return {
type: ACCESS_TOKEN_SET,
token: token
};
};

View File

@@ -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,24 +1,137 @@
export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW';
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
import api, { getLinks } from '../api'
import Immutable from 'immutable';
import IntlMessageFormat from 'intl-messageformat';
export function dismissNotification(notification) {
return {
type: NOTIFICATION_DISMISS,
notification: notification
import { fetchRelationships } from './accounts';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
if (accountIds > 0) {
dispatch(fetchRelationships(accountIds));
}
};
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
dispatch({
type: NOTIFICATIONS_UPDATE,
notification,
account: notification.account,
status: notification.status,
meta: playSound ? { sound: 'boop' } : undefined
});
fetchRelatedRelationships(dispatch, [notification]);
// Desktop notifications
if (typeof window.Notification !== 'undefined' && showAlert) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = $('<p>').html(notification.status ? notification.status.content : '').text();
new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
}
};
};
export function clearNotifications() {
return {
type: NOTIFICATION_CLEAR
export function refreshNotifications() {
return (dispatch, getState) => {
dispatch(refreshNotificationsRequest());
const params = {};
const ids = getState().getIn(['notifications', 'items']);
if (ids.size > 0) {
params.since_id = ids.first().get('id');
}
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(refreshNotificationsSuccess(response.data, next ? next.uri : null));
fetchRelatedRelationships(dispatch, response.data);
}).catch(error => {
dispatch(refreshNotificationsFail(error));
});
};
};
export function showNotification(title, message) {
export function refreshNotificationsRequest() {
return {
type: NOTIFICATION_SHOW,
title: title,
message: message
type: NOTIFICATIONS_REFRESH_REQUEST
};
};
export function refreshNotificationsSuccess(notifications, next) {
return {
type: NOTIFICATIONS_REFRESH_SUCCESS,
notifications,
accounts: notifications.map(item => item.account),
statuses: notifications.map(item => item.status).filter(status => !!status),
next
};
};
export function refreshNotificationsFail(error) {
return {
type: NOTIFICATIONS_REFRESH_FAIL,
error
};
};
export function expandNotifications() {
return (dispatch, getState) => {
const url = getState().getIn(['notifications', 'next'], null);
if (url === null) {
return;
}
dispatch(expandNotificationsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
fetchRelatedRelationships(dispatch, response.data);
}).catch(error => {
dispatch(expandNotificationsFail(error));
});
};
};
export function expandNotificationsRequest() {
return {
type: NOTIFICATIONS_EXPAND_REQUEST
};
};
export function expandNotificationsSuccess(notifications, next) {
return {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
accounts: notifications.map(item => item.account),
statuses: notifications.map(item => item.status).filter(status => !!status),
next
};
};
export function expandNotificationsFail(error) {
return {
type: NOTIFICATIONS_EXPAND_FAIL,
error
};
};

View File

@@ -0,0 +1,51 @@
import api from '../api'
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR';
export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY';
export const SEARCH_RESET = 'SEARCH_RESET';
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
value
};
};
export function clearSearchSuggestions() {
return {
type: SEARCH_SUGGESTIONS_CLEAR
};
};
export function readySearchSuggestions(value, accounts) {
return {
type: SEARCH_SUGGESTIONS_READY,
value,
accounts
};
};
export function fetchSearchSuggestions(value) {
return (dispatch, getState) => {
if (getState().getIn(['search', 'loaded_value']) === value) {
return;
}
api(getState).get('/api/v1/accounts/search', {
params: {
q: value,
resolve: true,
limit: 4
}
}).then(response => {
dispatch(readySearchSuggestions(value, response.data));
});
};
};
export function resetSearch() {
return {
type: SEARCH_RESET
};
};

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export function changeSetting(key, value) {
return {
type: SETTING_CHANGE,
key,
value
};
};
export function saveSettings() {
return (_, getState) => {
axios.put('/api/web/settings', {
data: getState().get('settings').toJS()
});
};
};

View File

@@ -1,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,17 @@
import Immutable from 'immutable';
export const STORE_HYDRATE = 'STORE_HYDRATE';
const convertState = rawState =>
Immutable.fromJS(rawState, (k, v) =>
Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
Number.isNaN(x * 1) ? x : x * 1));
export function hydrateStore(rawState) {
const state = convertState(rawState);
return {
type: STORE_HYDRATE,
state
};
};

View File

@@ -1,37 +0,0 @@
import api from '../api';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export function fetchSuggestions() {
return (dispatch, getState) => {
dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v1/accounts/suggestions').then(response => {
dispatch(fetchSuggestionsSuccess(response.data));
}).catch(error => {
dispatch(fetchSuggestionsFail(error));
});
};
};
export function fetchSuggestionsRequest() {
return {
type: SUGGESTIONS_FETCH_REQUEST
};
};
export function fetchSuggestionsSuccess(suggestions) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
suggestions: suggestions
};
};
export function fetchSuggestionsFail(error) {
return {
type: SUGGESTIONS_FETCH_FAIL,
error: error
};
};

View File

@@ -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,6 +12,8 @@ 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 const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export function refreshTimelineSuccess(timeline, statuses) {
return {
type: TIMELINE_REFRESH_SUCCESS,
@@ -20,32 +23,61 @@ export function refreshTimelineSuccess(timeline, statuses) {
};
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')]);
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
type: TIMELINE_DELETE,
id,
accountId,
references,
reblogOf
});
};
};
export function refreshTimelineRequest(timeline) {
export function refreshTimelineRequest(timeline, id) {
return {
type: TIMELINE_REFRESH_REQUEST,
timeline: timeline
timeline,
id
};
};
export function refreshTimeline(timeline) {
export function refreshTimeline(timeline, id = null) {
return function (dispatch, getState) {
dispatch(refreshTimelineRequest(timeline));
dispatch(refreshTimelineRequest(timeline, id));
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let path = timeline;
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
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));
}).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error));
@@ -56,18 +88,29 @@ export function refreshTimeline(timeline) {
export function refreshTimelineFail(timeline, error) {
return {
type: TIMELINE_REFRESH_FAIL,
timeline: timeline,
error: error
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, 'items'], Immutable.List()).last();
if (!lastId) {
// If timeline is empty, don't try to load older posts since there are none
return;
}
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));
@@ -78,22 +121,30 @@ export function expandTimeline(timeline) {
export function expandTimelineRequest(timeline) {
return {
type: TIMELINE_EXPAND_REQUEST,
timeline: timeline
timeline
};
};
export function expandTimelineSuccess(timeline, statuses) {
return {
type: TIMELINE_EXPAND_SUCCESS,
timeline: timeline,
statuses: statuses
timeline,
statuses
};
};
export function expandTimelineFail(timeline, error) {
return {
type: TIMELINE_EXPAND_FAIL,
timeline: timeline,
error: error
timeline,
error
};
};
export function scrollTopTimeline(timeline, top) {
return {
type: TIMELINE_SCROLL_TOP,
timeline,
top
};
};

View File

@@ -1,4 +1,15 @@
import axios from 'axios';
import LinkHeader from 'http-link-header';
export const getLinks = response => {
const value = response.headers.link;
if (!value) {
return { refs: [] };
}
return LinkHeader.parse(value);
};
export default getState => axios.create({
headers: {
@@ -6,6 +17,10 @@ export default getState => axios.create({
},
transformResponse: [function (data) {
return JSON.parse(data);
try {
return JSON.parse(data);
} catch(Exception) {
return data;
}
}]
});

View File

@@ -0,0 +1,115 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from './avatar';
import DisplayName from './display_name';
import Permalink from './permalink';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
});
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',
height: '18px'
};
const Account = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
},
getDefaultProps () {
return {
withNote: true
};
},
mixins: [PureRenderMixin],
handleFollow () {
this.props.onFollow(this.props.account);
},
handleBlock () {
this.props.onBlock(this.props.account);
},
render () {
const { account, me, withNote, intl } = 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 && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
if (requested) {
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
} else if (blocking) {
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}
}
return (
<div style={outerStyle}>
<div style={{ display: 'flex' }}>
<Permalink key={account.get('id')} style={itemStyle} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
<DisplayName account={account} />
</Permalink>
<div style={buttonsStyle}>
{buttons}
</div>
</div>
{note}
</div>
);
}
});
export default injectIntl(Account);

View File

@@ -0,0 +1,202 @@
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import ImmutablePropTypes from 'react-immutable-proptypes';
const textAtCursorMatchesToken = (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, null];
}
word = word.trim().toLowerCase().slice(1);
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
const AutosuggestTextarea = React.createClass({
propTypes: {
value: React.PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: React.PropTypes.bool,
fileDropDate: React.PropTypes.instanceOf(Date),
placeholder: React.PropTypes.string,
onSuggestionSelected: React.PropTypes.func.isRequired,
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
onChange: React.PropTypes.func.isRequired,
onKeyUp: React.PropTypes.func,
onKeyDown: React.PropTypes.func
},
getInitialState () {
return {
isFileDragging: false,
fileDraggingDate: undefined,
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0
};
},
onChange (e) {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token != null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
},
onKeyDown (e) {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
switch(e.key) {
case 'Escape':
if (!suggestionsHidden) {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken != null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
},
onBlur () {
this.setState({ suggestionsHidden: true });
},
onSuggestionClick (suggestion, e) {
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
},
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
const fileDropDate = nextProps.fileDropDate;
const { isFileDragging, fileDraggingDate } = this.state;
/*
* We can't detect drop events, because they might not be on the textarea (the app allows dropping anywhere in the
* window). Instead, on-drop, we notify this textarea to stop its hover effect by passing in a prop with the
* drop-date.
*/
if (isFileDragging && fileDraggingDate && fileDropDate // if dragging when props updated, and dates aren't undefined
&& fileDropDate > fileDraggingDate) { // and if the drop date is now greater than when we started dragging
// then we should stop dragging
this.setState({
isFileDragging: false
});
}
},
setTextarea (c) {
this.textarea = c;
},
onDragEnter () {
this.setState({
isFileDragging: true,
fileDraggingDate: new Date()
})
},
onDragExit () {
this.setState({
isFileDragging: false
})
},
render () {
const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
return (
<div className='autosuggest-textarea'>
<textarea
ref={this.setTextarea}
className={className}
disabled={disabled}
placeholder={placeholder}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onBlur={this.onBlur}
onDragEnter={this.onDragEnter}
onDragExit={this.onDragExit}
/>
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
{suggestions.map((suggestion, i) => (
<div key={suggestion} className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} onClick={this.onSuggestionClick.bind(this, suggestion)}>
<AutosuggestAccountContainer id={suggestion} />
</div>
))}
</div>
</div>
);
}
});
export default AutosuggestTextarea;

View File

@@ -4,14 +4,15 @@ const Avatar = React.createClass({
propTypes: {
src: React.PropTypes.string.isRequired,
size: React.PropTypes.number.isRequired
size: React.PropTypes.number.isRequired,
style: React.PropTypes.object
},
mixins: [PureRenderMixin],
render () {
return (
<div style={{ width: `${this.props.size}px`, height: `${this.props.size}px` }}>
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
</div>
);

View File

@@ -7,7 +7,14 @@ const Button = React.createClass({
onClick: React.PropTypes.func,
disabled: React.PropTypes.bool,
block: React.PropTypes.bool,
secondary: React.PropTypes.bool
secondary: React.PropTypes.bool,
size: React.PropTypes.number,
},
getDefaultProps () {
return {
size: 36
};
},
mixins: [PureRenderMixin],
@@ -20,7 +27,7 @@ const Button = React.createClass({
render () {
const style = {
fontFamily: 'Roboto',
fontFamily: 'inherit',
display: this.props.block ? 'block' : 'inline-block',
width: this.props.block ? '100%' : 'auto',
position: 'relative',
@@ -32,16 +39,16 @@ const Button = React.createClass({
fontWeight: '500',
letterSpacing: '0',
textTransform: 'uppercase',
padding: '0 16px',
height: '36px',
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
cursor: 'pointer',
lineHeight: '36px',
lineHeight: `${this.props.size}px`,
borderRadius: '4px',
textDecoration: 'none'
};
return (
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={style}>
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
{this.props.text || this.props.children}
</button>
);

View File

@@ -0,0 +1,41 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { FormattedMessage } from 'react-intl';
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} className='column-back-button'>
<i className='fa fa-fw fa-chevron-left' style={iconStyle} />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>
);
}
});
export default ColumnBackButton;

View File

@@ -0,0 +1,60 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Motion, spring } from 'react-motion';
const iconStyle = {
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '0',
top: '-48px',
cursor: 'pointer'
};
const ColumnCollapsable = React.createClass({
propTypes: {
icon: React.PropTypes.string.isRequired,
fullHeight: React.PropTypes.number.isRequired,
children: React.PropTypes.node,
onCollapse: React.PropTypes.func
},
getInitialState () {
return {
collapsed: true
};
},
mixins: [PureRenderMixin],
handleToggleCollapsed () {
const currentState = this.state.collapsed;
this.setState({ collapsed: !currentState });
if (!currentState && this.props.onCollapse) {
this.props.onCollapse();
}
},
render () {
const { icon, fullHeight, children } = this.props;
const { collapsed } = this.state;
return (
<div style={{ position: 'relative' }}>
<div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
{({ opacity, height }) =>
<div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
{children}
</div>
}
</Motion>
</div>
);
}
});
export default ColumnCollapsable;

View File

@@ -1,5 +1,7 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
import emojify from '../emoji';
const DisplayName = React.createClass({
@@ -10,15 +12,12 @@ const DisplayName = React.createClass({
mixins: [PureRenderMixin],
render () {
let displayName = this.props.account.get('display_name');
if (displayName.length === 0) {
displayName = this.props.account.get('username');
}
const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return (
<span style={{ display: 'block', maxWidth: '100%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
<strong style={{ fontWeight: 'bold' }}>{displayName}</strong> <span style={{ fontSize: '14px' }}>@{this.props.account.get('acct')}</span>
<span style={{ display: 'block', maxWidth: '100%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }} className='display-name'>
<strong style={{ fontWeight: '500' }} dangerouslySetInnerHTML={displayNameHTML} /> <span style={{ fontSize: '14px' }}>@{this.props.account.get('acct')}</span>
</span>
);
}

View File

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

View File

@@ -1,19 +1,26 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { Motion, spring } from 'react-motion';
const IconButton = React.createClass({
propTypes: {
title: React.PropTypes.string.isRequired,
icon: React.PropTypes.string.isRequired,
onClick: React.PropTypes.func.isRequired,
onClick: React.PropTypes.func,
size: React.PropTypes.number,
active: React.PropTypes.bool
active: React.PropTypes.bool,
style: React.PropTypes.object,
activeStyle: React.PropTypes.object,
disabled: React.PropTypes.bool,
animate: React.PropTypes.bool
},
getDefaultProps () {
return {
size: 18,
active: false
active: false,
disabled: false,
animate: false
};
},
@@ -21,23 +28,42 @@ const IconButton = React.createClass({
handleClick (e) {
e.preventDefault();
this.props.onClick();
e.stopPropagation();
if (!this.props.disabled) {
this.props.onClick();
}
},
render () {
const style = {
let 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`,
...this.props.style
};
if (this.props.active) {
style = { ...style, ...this.props.activeStyle };
}
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>
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) =>
<button
aria-label={this.props.title}
title={this.props.title}
className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
onClick={this.handleClick}
style={style}>
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
}
</Motion>
);
}

View File

@@ -0,0 +1,78 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import { Motion, spring } from 'react-motion';
import { injectIntl } from 'react-intl';
const overlayStyle = {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignContent: 'center',
flexDirection: 'row',
zIndex: '9999'
};
const dialogStyle = {
color: '#282c37',
boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)',
margin: 'auto',
position: 'relative'
};
const closeStyle = {
position: 'absolute',
top: '4px',
right: '4px'
};
const Lightbox = React.createClass({
propTypes: {
isVisible: React.PropTypes.bool,
onOverlayClicked: React.PropTypes.func,
onCloseClicked: React.PropTypes.func,
intl: React.PropTypes.object.isRequired,
children: React.PropTypes.node
},
mixins: [PureRenderMixin],
componentDidMount () {
this._listener = e => {
if (e.key === 'Escape') {
this.props.onCloseClicked();
}
};
window.addEventListener('keyup', this._listener);
},
componentWillUnmount () {
window.removeEventListener('keyup', this._listener);
},
render () {
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
return (
<Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
{({ backgroundOpacity, opacity, y }) =>
<div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
<div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
{children}
</div>
</div>
}
</Motion>
);
}
});
export default injectIntl(Lightbox);

View File

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

View File

@@ -1,74 +1,166 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
});
const outerStyle = {
marginTop: '8px',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
position: 'relative'
};
const spoilerStyle = {
background: '#000',
color: '#fff',
textAlign: 'center',
height: '100%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
};
const spoilerSpanStyle = {
display: 'block',
fontSize: '14px',
};
const spoilerSubSpanStyle = {
display: 'block',
fontSize: '11px',
fontWeight: '500'
};
const spoilerButtonStyle = {
position: 'absolute',
top: '6px',
left: '8px',
zIndex: '100'
};
const MediaGallery = React.createClass({
getInitialState () {
return {
visible: false
};
},
propTypes: {
sensitive: React.PropTypes.bool,
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();
},
handleOpen () {
this.setState({ visible: !this.state.visible });
},
render () {
var children = this.props.media.take(4);
var size = children.size;
const { media, intl, sensitive } = this.props;
children = children.map((attachment, i) => {
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
let children;
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}
if (sensitive && !this.state.visible) {
children = (
<div style={spoilerStyle} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
const size = media.take(4).size;
if (size === 2) {
if (i === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (i === 0) {
right = '2px';
} else if (i > 0) {
left = '2px';
children = media.take(4).map((attachment, i) => {
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (i === 1) {
bottom = '2px';
} else if (i > 1) {
top = '2px';
}
} else if (size === 4) {
if (i === 0 || i === 2) {
right = '2px';
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}
if (i === 1 || i === 3) {
left = '2px';
if (size === 2) {
if (i === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (i === 0) {
right = '2px';
} else if (i > 0) {
left = '2px';
}
if (i === 1) {
bottom = '2px';
} else if (i > 1) {
top = '2px';
}
} else if (size === 4) {
if (i === 0 || i === 2) {
right = '2px';
}
if (i === 1 || i === 3) {
left = '2px';
}
if (i < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
if (i < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
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('remote_url') ? attachment.get('remote_url') : 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 <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' }} />;
});
let spoilerButton;
if (sensitive) {
spoilerButton = (
<div style={spoilerButtonStyle} >
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
</div>
);
}
return (
<div style={{ marginTop: '8px', overflow: 'hidden', width: '100%', height: `${this.props.height}px`, boxSizing: 'border-box' }}>
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
{spoilerButton}
{children}
</div>
);
@@ -76,4 +168,4 @@ const MediaGallery = React.createClass({
});
export default MediaGallery;
export default injectIntl(MediaGallery);

View File

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

View File

@@ -0,0 +1,27 @@
const Permalink = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
href: React.PropTypes.string.isRequired,
to: React.PropTypes.string.isRequired
},
handleClick (e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(this.props.to);
}
},
render () {
const { href, children, ...other } = this.props;
return <a href={href} onClick={this.handleClick} {...other}>{children}</a>;
}
});
export default Permalink;

View File

@@ -1,59 +1,18 @@
import moment from 'moment';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { injectIntl, FormattedRelative } from 'react-intl';
moment.updateLocale('en', {
relativeTime : {
future: "in %s",
past: "%s",
s: "%ds",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
M: "1mo",
MM: "%dmo",
y: "1y",
yy: "%dy"
}
});
const RelativeTimestamp = ({ intl, timestamp }) => {
const date = new Date(timestamp);
const RelativeTimestamp = React.createClass({
return (
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
<FormattedRelative value={date} />
</time>
);
};
getInitialState () {
return {
text: ''
};
},
RelativeTimestamp.propTypes = {
intl: React.PropTypes.object.isRequired,
timestamp: React.PropTypes.string.isRequired
};
propTypes: {
timestamp: React.PropTypes.string.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this._updateMomentText();
this.interval = setInterval(this._updateMomentText, 60000);
},
componentWillUnmount () {
clearInterval(this.interval);
},
_updateMomentText () {
this.setState({ text: moment(this.props.timestamp).fromNow() });
},
render () {
return (
<span>
{this.state.text}
</span>
);
}
});
export default RelativeTimestamp;
export default injectIntl(RelativeTimestamp);

View File

@@ -1,12 +1,24 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from './avatar';
import RelativeTimestamp from './relative_timestamp';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import DisplayName from './display_name';
import MediaGallery from './media_gallery';
import VideoPlayer from './video_player';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import Avatar from './avatar';
import RelativeTimestamp from './relative_timestamp';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import DisplayName from './display_name';
import MediaGallery from './media_gallery';
import VideoPlayer from './video_player';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl';
import emojify from '../emoji';
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
const outerStyle = {
padding: '8px 10px',
paddingLeft: '68px',
position: 'relative',
minHeight: '48px',
borderBottom: '1px solid #363c4b',
cursor: 'default'
};
const Status = React.createClass({
@@ -15,13 +27,16 @@ 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,
onBlock: React.PropTypes.func,
me: React.PropTypes.number,
muted: React.PropTypes.bool
},
mixins: [PureRenderMixin],
@@ -36,13 +51,15 @@ const Status = React.createClass({
e.preventDefault();
this.context.router.push(`/accounts/${id}`);
}
e.stopPropagation();
},
render () {
let media = '';
let { status, ...other } = this.props;
const { status, now, ...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']);
@@ -51,11 +68,13 @@ const Status = React.createClass({
displayName = status.getIn(['account', 'username']);
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return (
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
<div style={{ cursor: 'default' }}>
<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'}}>{displayName}</strong></a> reblogged
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} reblogged' values={{ name: <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong style={{ color: '#616b86'}} dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
</div>
<Status {...other} wrapped={true} status={status.get('reblog')} />
@@ -63,23 +82,23 @@ const Status = React.createClass({
);
}
if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').size > 0 && !this.props.muted) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />;
} else {
media = <MediaGallery media={status.get('media_attachments')} height={110} />;
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
}
}
return (
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
<div className={this.props.muted ? 'muted' : ''} style={outerStyle}>
<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' }}>
<div style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
<div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
<Avatar src={status.getIn(['account', 'avatar'])} size={48} />
</div>
@@ -87,7 +106,7 @@ const Status = React.createClass({
</a>
</div>
<StatusContent status={status} />
<StatusContent status={status} onClick={this.handleClick} />
{media}

View File

@@ -1,21 +1,39 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import DropdownMenu from './dropdown_menu';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import DropdownMenu from './dropdown_menu';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention' },
block: { id: 'account.block', defaultMessage: 'Block' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand' }
});
const StatusActionBar = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
status: ImmutablePropTypes.map.isRequired,
onReply: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func
onDelete: React.PropTypes.func,
onMention: React.PropTypes.func,
onBlock: React.PropTypes.func
},
mixins: [PureRenderMixin],
handleReplyClick () {
this.props.onReply(this.props.status);
this.props.onReply(this.props.status, this.context.router);
},
handleFavouriteClick () {
@@ -30,22 +48,39 @@ const StatusActionBar = React.createClass({
this.props.onDelete(this.props.status);
},
handleMentionClick () {
this.props.onMention(this.props.status.get('account'), this.context.router);
},
handleBlockClick () {
this.props.onBlock(this.props.status.get('account'));
},
handleOpen () {
this.context.router.push(`/statuses/${this.props.status.get('id')}`);
},
render () {
const { status, me } = this.props;
const { status, me, intl } = this.props;
let menu = [];
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (status.getIn(['account', 'id']) === me) {
menu.push({ text: 'Delete', action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
}
return (
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
<div style={{ width: '18px', height: '18px', float: 'left' }}>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" />
</div>
</div>
);
@@ -53,4 +88,4 @@ const StatusActionBar = React.createClass({
});
export default StatusActionBar;
export default injectIntl(StatusActionBar);

View File

@@ -1,5 +1,6 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import emojify from '../emoji';
const StatusContent = React.createClass({
@@ -8,7 +9,8 @@ const StatusContent = React.createClass({
},
propTypes: {
status: ImmutablePropTypes.map.isRequired
status: ImmutablePropTypes.map.isRequired,
onClick: React.PropTypes.func
},
mixins: [PureRenderMixin],
@@ -23,11 +25,14 @@ const StatusContent = React.createClass({
if (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, false);
}
link.addEventListener('click', this.onNormalClick, false);
}
},
@@ -36,8 +41,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(`/timelines/tag/${hashtag}`);
}
},
onNormalClick (e) {
@@ -45,8 +57,11 @@ const StatusContent = React.createClass({
},
render () {
const content = { __html: this.props.status.get('content') };
return <div className='status__content' dangerouslySetInnerHTML={content} />;
const { status, onClick } = this.props;
const content = { __html: emojify(status.get('content')) };
return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />;
},
});

View File

@@ -1,17 +1,23 @@
import Status from './status';
import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { ScrollContainer } from 'react-router-scroll';
import StatusContainer from '../containers/status_container';
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
onScrollToTop: React.PropTypes.func,
onScroll: React.PropTypes.func,
trackScroll: React.PropTypes.bool
},
getDefaultProps () {
return {
trackScroll: true
};
},
mixins: [PureRenderMixin],
@@ -19,23 +25,49 @@ const StatusList = React.createClass({
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
this.props.onScrollToBottom();
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
} else if (this.props.onScroll) {
this.props.onScroll();
}
},
componentDidUpdate (prevProps) {
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
const node = ReactDOM.findDOMNode(this);
if (node.scrollTop > 0) {
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
}
}
},
render () {
const { statuses, onScrollToBottom, ...other } = this.props;
const { statusIds, onScrollToBottom, trackScroll } = this.props;
return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}>
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} />;
})}
</div>
</div>
);
if (trackScroll) {
return (
<ScrollContainer scrollKey='status-list'>
{scrollableArea}
</ScrollContainer>
);
} else {
return scrollableArea;
}
}
});

View File

@@ -1,12 +1,60 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
});
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 spoilerStyle = {
marginTop: '8px',
background: '#000',
color: '#fff',
textAlign: 'center',
height: '100%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
};
const spoilerSpanStyle = {
display: 'block',
fontSize: '14px'
};
const spoilerSubSpanStyle = {
display: 'block',
fontSize: '11px',
fontWeight: '500'
};
const VideoPlayer = React.createClass({
propTypes: {
media: ImmutablePropTypes.map.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number
height: React.PropTypes.number,
sensitive: React.PropTypes.bool
},
getDefaultProps () {
@@ -18,6 +66,7 @@ const VideoPlayer = React.createClass({
getInitialState () {
return {
visible: false,
muted: true
};
},
@@ -28,15 +77,48 @@ 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();
}
},
handleOpen () {
this.setState({ visible: true });
},
render () {
const { media, intl, width, height, sensitive } = this.props;
if (sensitive && !this.state.visible) {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else if (!sensitive && !this.state.visible) {
return (
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
</div>
);
}
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={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div>
);
}
});
export default VideoPlayer;
export default injectIntl(VideoPlayer);

View File

@@ -0,0 +1,40 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../selectors';
import Account from '../components/account';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount
} 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')));
}
},
onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Account);

View File

@@ -1,65 +1,94 @@
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';
} from '../actions/timelines';
import { updateNotifications } from '../actions/notifications';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import {
applyRouterMiddleware,
useRouterHistory,
Router,
Route,
hashHistory,
IndexRedirect,
IndexRoute
} from 'react-router';
import UI from '../features/ui';
import Account from '../features/account';
import Status from '../features/status';
import GettingStarted from '../features/getting_started';
import PublicTimeline from '../features/public_timeline';
import AccountTimeline from '../features/account_timeline';
import HomeTimeline from '../features/home_timeline';
import MentionsTimeline from '../features/mentions_timeline';
import Compose from '../features/compose';
} 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';
import Notifications from '../features/notifications';
import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses';
import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
import es from 'react-intl/locale-data/es';
import fr from 'react-intl/locale-data/fr';
import pt from 'react-intl/locale-data/pt';
import hu from 'react-intl/locale-data/hu';
import uk from 'react-intl/locale-data/uk';
import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store';
const store = configureStore();
store.dispatch(hydrateStore(window.INITIAL_STATE));
const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web'
});
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
const Mastodon = React.createClass({
propTypes: {
token: React.PropTypes.string.isRequired,
timelines: React.PropTypes.object,
account: React.PropTypes.string
locale: React.PropTypes.string.isRequired
},
mixins: [PureRenderMixin],
componentWillMount() {
store.dispatch(setAccessToken(this.props.token));
store.dispatch(setAccountSelf(JSON.parse(this.props.account)));
const { locale } = this.props;
if (typeof App !== 'undefined') {
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
received (data) {
switch(data.type) {
case 'update':
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
case 'delete':
return store.dispatch(deleteFromTimelines(data.id));
case 'merge':
case 'unmerge':
return store.dispatch(refreshTimeline('home'));
case 'block':
return store.dispatch(refreshTimeline('mentions'));
case 'update':
store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
break;
case 'delete':
store.dispatch(deleteFromTimelines(data.id));
break;
case 'notification':
store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
break;
}
}
});
}
// Desktop notifications
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission();
}
},
componentWillUnmount () {
@@ -69,22 +98,41 @@ const Mastodon = React.createClass({
},
render () {
const { locale } = this.props;
return (
<Provider store={store}>
<Router history={hashHistory}>
<Route path='/' component={UI}>
<IndexRoute component={GettingStarted} />
<Route path='/statuses/new' component={Compose} />
<Route path='/statuses/home' component={HomeTimeline} />
<Route path='/statuses/mentions' component={MentionsTimeline} />
<Route path='/statuses/all' component={PublicTimeline} />
<Route path='/statuses/:statusId' component={Status} />
<Route path='/accounts/:accountId' component={Account}>
<IndexRoute component={AccountTimeline} />
<IntlProvider locale={locale} messages={getMessagesForLocale(locale)}>
<Provider store={store}>
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={UI}>
<IndexRedirect to="/getting-started" />
<Route path='getting-started' component={GettingStarted} />
<Route path='timelines/home' component={HomeTimeline} />
<Route path='timelines/mentions' component={MentionsTimeline} />
<Route path='timelines/public' component={PublicTimeline} />
<Route path='timelines/tag/:id' component={HashtagTimeline} />
<Route path='notifications' component={Notifications} />
<Route path='favourites' component={FavouritedStatuses} />
<Route path='statuses/new' component={Compose} />
<Route path='statuses/:statusId' component={Status} />
<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='followers' component={Followers} />
<Route path='following' component={Following} />
</Route>
<Route path='follow_requests' component={FollowRequests} />
<Route path='*' component={GenericNotFound} />
</Route>
</Route>
</Router>
</Provider>
</Router>
</Provider>
</IntlProvider>
);
}

View File

@@ -0,0 +1,111 @@
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 { blockAccount } from '../actions/accounts';
import { deleteStatus } from '../actions/statuses';
import { openMedia } from '../actions/modal';
import { createSelector } from 'reselect'
import { isMobile } from '../is_mobile'
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, router) {
dispatch(replyCompose(status, router));
},
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, router) {
dispatch(mentionCompose(account));
if (isMobile(window.innerWidth)) {
router.push('/statuses/new');
}
},
onOpenMedia (url) {
dispatch(openMedia(url));
},
onBlock (account) {
dispatch(blockAccount(account.get('id')));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(
connect(makeMapStateToPropsInner)(
connect(makeMapStateToPropsLast)(Status)
)
);

View File

@@ -0,0 +1,9 @@
import emojione from 'emojione';
emojione.imageType = 'png';
emojione.sprites = false;
emojione.imagePathPNG = '/emoji/';
export default function emojify(text) {
return emojione.toImage(text);
};

View File

@@ -1,61 +1,94 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenu from '../../../components/dropdown_menu';
import DropdownMenu from '../../../components/dropdown_menu';
import { Link } from 'react-router';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
const messages = defineMessages({
mention: { id: 'account.mention', defaultMessage: 'Mention' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
block: { id: 'account.block', defaultMessage: 'Block' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
block: { id: 'account.block', defaultMessage: 'Block' }
});
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({
propTypes: {
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired
onFollow: React.PropTypes.func,
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
render () {
const { account, me } = this.props;
const { account, me, intl } = this.props;
let menu = [];
menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
if (account.get('id') === me) {
menu.push({ text: 'Edit profile', href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
} else if (account.getIn(['relationship', 'blocking'])) {
menu.push({ text: 'Unblock', action: this.props.onBlock });
menu.push({ text: intl.formatMessage(messages.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 });
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
} else {
menu.push({ text: 'Follow', action: this.props.onFollow });
menu.push({ text: 'Block', action: this.props.onBlock });
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
}
return (
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
</div>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px 5px' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
</div>
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '1px solid #363c4b' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
</div>
<div style={outerStyle}>
<div style={outerDropdownStyle}>
<DropdownMenu items={menu} icon='bars' size={24} direction="right" />
</div>
<div style={{ padding: '10px', flex: '1 1 auto' }}>
<DropdownMenu items={menu} icon='bars' size={24} />
<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' }}><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}><FormattedNumber value={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' }}><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}><FormattedNumber value={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' }}><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}><FormattedNumber value={account.get('followers_count')} /></span>
</Link>
</div>
</div>
);
},
}
});
export default ActionBar;
export default injectIntl(ActionBar);

View File

@@ -1,44 +1,81 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
});
const Header = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
render () {
const { account, me } = this.props;
const { account, me, intl } = this.props;
let displayName = account.get('display_name');
let info = '';
let info = '';
let actionBtn = '';
let lockedIcon = '';
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>
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' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
}
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
<IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else {
actionBtn = (
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
</div>
);
}
}
if (account.get('locked')) {
lockedIcon = <i className='fa fa-lock' />;
}
const content = { __html: emojify(account.get('note')) };
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return (
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
<div className='account__header' style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}>
<div style={{ background: 'rgba(47, 52, 65, 0.9)', 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: '15px' }}>
<div className='account__header__avatar' 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={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
</a>
<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={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
{actionBtn}
</div>
</div>
);
@@ -46,4 +83,4 @@ const Header = React.createClass({
});
export default Header;
export default injectIntl(Header);

View File

@@ -10,22 +10,35 @@ import {
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
import { mentionCompose } from '../../actions/compose';
import Header from './components/header';
import {
getAccountTimeline,
getAccount
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';
import { isMobile } from '../../is_mobile'
const mapStateToProps = (state, props) => ({
account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const Account = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
@@ -39,7 +52,7 @@ const Account = React.createClass({
this.props.dispatch(fetchAccount(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)));
}
@@ -61,6 +74,13 @@ const Account = React.createClass({
}
},
handleMention () {
this.props.dispatch(mentionCompose(this.props.account));
if (isMobile(window.innerWidth)) {
this.context.router.push('/statuses/new');
}
},
render () {
const { account, me } = this.props;
@@ -74,9 +94,9 @@ const Account = React.createClass({
return (
<Column>
<Header account={account} me={me} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
<ColumnBackButton />
<Header account={account} me={me} onFollow={this.handleFollow} />
<ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} />
{this.props.children}
</Column>
@@ -85,4 +105,4 @@ const Account = React.createClass({
});
export default connect(mapStateToProps)(Account);
export default connect(makeMapStateToProps)(Account);

View File

@@ -1,24 +1,16 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getAccountTimeline } from '../../selectors';
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 { deleteStatus } from '../../actions/statuses';
import { replyCompose } from '../../actions/compose';
import {
favourite,
reblog,
unreblog,
unfavourite
} from '../../actions/interactions';
import StatusList from '../../components/status_list';
} from '../../actions/accounts';
import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
const mapStateToProps = (state, props) => ({
statuses: getAccountTimeline(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
me: state.getIn(['meta', 'me'])
});
const AccountTimeline = React.createClass({
@@ -26,7 +18,8 @@ const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statuses: ImmutablePropTypes.list
statusIds: ImmutablePropTypes.list,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
@@ -41,38 +34,18 @@ const AccountTimeline = 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(Number(this.props.params.accountId)));
},
render () {
const { statuses, me } = this.props;
const { statusIds, me } = this.props;
return <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} onDelete={this.handleDelete} />
if (!statusIds) {
return <LoadingIndicator />;
}
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
}
});

View File

@@ -0,0 +1,11 @@
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
const AutosuggestAccount = ({ account }) => (
<div style={{ overflow: 'hidden' }}>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div>
<DisplayName account={account} />
</div>
);
export default AutosuggestAccount;

View File

@@ -0,0 +1,168 @@
import CharacterCounter from './character_counter';
import Button from '../../../components/button';
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 AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
import { debounce } from 'react-decoration';
import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import { Motion, spring } from 'react-motion';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
});
const ComposeForm = React.createClass({
propTypes: {
intl: React.PropTypes.object.isRequired,
text: React.PropTypes.string.isRequired,
suggestion_token: React.PropTypes.string,
suggestions: ImmutablePropTypes.list,
sensitive: React.PropTypes.bool,
unlisted: React.PropTypes.bool,
private: React.PropTypes.bool,
fileDropDate: React.PropTypes.instanceOf(Date),
is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
media_count: React.PropTypes.number,
me: React.PropTypes.number,
onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
onCancelReply: React.PropTypes.func.isRequired,
onClearSuggestions: React.PropTypes.func.isRequired,
onFetchSuggestions: React.PropTypes.func.isRequired,
onSuggestionSelected: React.PropTypes.func.isRequired,
onChangeSensitivity: React.PropTypes.func.isRequired,
onChangeVisibility: React.PropTypes.func.isRequired,
onChangeListability: React.PropTypes.func.isRequired,
},
mixins: [PureRenderMixin],
handleChange (e) {
this.props.onChange(e.target.value);
},
handleKeyDown (e) {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.props.onSubmit();
}
},
handleSubmit () {
this.props.onSubmit();
},
onSuggestionsClearRequested () {
this.props.onClearSuggestions();
},
@debounce(500)
onSuggestionsFetchRequested (token) {
this.props.onFetchSuggestions(token);
},
onSuggestionSelected (tokenStart, token, value) {
this.props.onSuggestionSelected(tokenStart, token, value);
},
handleChangeSensitivity (e) {
this.props.onChangeSensitivity(e.target.checked);
},
handleChangeVisibility (e) {
this.props.onChangeVisibility(e.target.checked);
},
handleChangeListability (e) {
this.props.onChangeListability(e.target.checked);
},
componentDidUpdate (prevProps) {
if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
// If replying to zero or one users, places the cursor at the end of the textbox.
// If replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
const selectionStart = this.props.text.search(/\s/) + 1;
const selectionEnd = this.props.text.length;
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
}
},
setAutosuggestTextarea (c) {
this.autosuggestTextarea = c;
},
render () {
const { intl } = this.props;
let replyArea = '';
const disabled = this.props.is_submitting || this.props.is_uploading;
if (this.props.in_reply_to) {
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
}
let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
return (
<div style={{ padding: '10px' }}>
{replyArea}
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
fileDropDate={this.props.fileDropDate}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
/>
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
<UploadButtonContainer style={{ paddingTop: '4px' }} />
</div>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #282c37', paddingTop: '10px' }}>
<Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
</label>
<Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
{({ opacity, height }) =>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
<Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span>
</label>
}
</Motion>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(this.props.media_count === 0 ? 0 : 100), height: spring(this.props.media_count === 0 ? 0 : 39.5) }}>
{({ opacity, height }) =>
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
<Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark content as sensitive' /></span>
</label>
}
</Motion>
</div>
);
}
});
export default injectIntl(ComposeForm);

View File

@@ -0,0 +1,75 @@
import { Link } from 'react-router';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
});
const outerStyle = {
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflowY: 'hidden'
};
const innerStyle = {
boxSizing: 'border-box',
padding: '0',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
flexGrow: '1'
};
const tabStyle = {
display: 'block',
flex: '1 1 auto',
padding: '15px',
paddingBottom: '13px',
color: '#9baec8',
textDecoration: 'none',
textAlign: 'center',
fontSize: '16px',
borderBottom: '2px solid transparent'
};
const tabActiveStyle = {
color: '#2b90d9',
borderBottom: '2px solid #2b90d9'
};
const Drawer = ({ children, withHeader, intl }) => {
let header = '';
if (withHeader) {
header = (
<div className='drawer__header'>
<Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
<Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
<a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
<a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
</div>
);
}
return (
<div className='drawer' style={outerStyle}>
{header}
<div className='drawer__inner' style={innerStyle}>
{children}
</div>
</div>
);
};
Drawer.propTypes = {
withHeader: React.PropTypes.bool,
children: React.PropTypes.node,
intl: React.PropTypes.object
};
export default injectIntl(Drawer);

View File

@@ -0,0 +1,32 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name';
import Permalink from '../../../components/permalink';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router';
const NavigationBar = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired
},
mixins: [PureRenderMixin],
render () {
return (
<div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
<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' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
</div>
);
}
});
export default NavigationBar;

View File

@@ -0,0 +1,59 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }
});
const ReplyIndicator = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
status: ImmutablePropTypes.map.isRequired,
onCancel: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleClick () {
this.props.onCancel();
},
handleAccountClick (e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
},
render () {
const { intl } = this.props;
const content = { __html: emojify(this.props.status.get('content')) };
return (
<div style={{ background: '#9baec8', padding: '10px' }}>
<div style={{ overflow: 'hidden', marginBottom: '5px' }}>
<div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#282c37', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
<DisplayName account={this.props.status.get('account')} />
</a>
</div>
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
</div>
);
}
});
export default injectIntl(ReplyIndicator);

View File

@@ -0,0 +1,133 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Autosuggest from 'react-autosuggest';
import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
import { debounce } from 'react-decoration';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
});
const getSuggestionValue = suggestion => suggestion.value;
const renderSuggestion = suggestion => {
if (suggestion.type === 'account') {
return <AutosuggestAccountContainer id={suggestion.id} />;
} else {
return <span>#{suggestion.id}</span>
}
};
const renderSectionTitle = section => (
<strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong>
);
const getSectionSuggestions = section => section.items;
const outerStyle = {
padding: '10px',
lineHeight: '20px',
position: 'relative'
};
const inputStyle = {
boxSizing: 'border-box',
display: 'block',
width: '100%',
border: 'none',
padding: '10px',
paddingRight: '30px',
fontFamily: 'inherit',
background: '#282c37',
color: '#9baec8',
fontSize: '14px',
margin: '0'
};
const iconStyle = {
position: 'absolute',
top: '18px',
right: '20px',
color: '#9baec8',
fontSize: '18px',
pointerEvents: 'none'
};
const Search = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
suggestions: React.PropTypes.array.isRequired,
value: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func.isRequired,
onClear: React.PropTypes.func.isRequired,
onFetch: React.PropTypes.func.isRequired,
onReset: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
onChange (_, { newValue }) {
if (typeof newValue !== 'string') {
return;
}
this.props.onChange(newValue);
},
onSuggestionsClearRequested () {
this.props.onClear();
},
@debounce(500)
onSuggestionsFetchRequested ({ value }) {
value = value.replace('#', '');
this.props.onFetch(value.trim());
},
onSuggestionSelected (_, { suggestion }) {
if (suggestion.type === 'account') {
this.context.router.push(`/accounts/${suggestion.id}`);
} else {
this.context.router.push(`/timelines/tag/${suggestion.id}`);
}
},
render () {
const inputProps = {
placeholder: this.props.intl.formatMessage(messages.placeholder),
value: this.props.value,
onChange: this.onChange,
style: inputStyle
};
return (
<div style={outerStyle}>
<Autosuggest
multiSection={true}
suggestions={this.props.suggestions}
focusFirstSuggestion={true}
focusInputOnSuggestionClick={false}
alwaysRenderSuggestions={false}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
renderSectionTitle={renderSectionTitle}
getSectionSuggestions={getSectionSuggestions}
inputProps={inputProps}
/>
<div style={iconStyle}><i className='fa fa-search' /></div>
</div>
);
},
});
export default injectIntl(Search);

View File

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

View File

@@ -0,0 +1,48 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
});
const UploadButton = React.createClass({
propTypes: {
disabled: React.PropTypes.bool,
onSelectFile: React.PropTypes.func.isRequired,
style: React.PropTypes.object,
resetFileKey: React.PropTypes.number,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
handleChange (e) {
if (e.target.files.length > 0) {
this.props.onSelectFile(e.target.files);
}
},
handleClick () {
this.fileElement.click();
},
setRef (c) {
this.fileElement = c;
},
render () {
const { intl, resetFileKey, disabled } = this.props;
return (
<div style={this.props.style}>
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
<input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
</div>
);
}
});
export default injectIntl(UploadButton);

View File

@@ -0,0 +1,44 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
});
const UploadForm = React.createClass({
propTypes: {
media: ImmutablePropTypes.list.isRequired,
is_uploading: React.PropTypes.bool,
onRemoveFile: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
render () {
const { intl, media } = this.props;
if (!media.size) {
return null;
}
const uploads = media.map(attachment => (
<div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
<div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
</div>
</div>
));
return (
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
{uploads}
</div>
);
}
});
export default injectIntl(UploadForm);

View File

@@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import AutosuggestAccount from '../components/autosuggest_account';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { id }) => ({
account: getAccount(state, id)
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(AutosuggestAccount);

View File

@@ -0,0 +1,79 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import {
changeCompose,
submitCompose,
cancelReplyCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSensitivity,
changeComposeVisibility,
changeComposeListability
} from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
sensitive: state.getIn(['compose', 'sensitive']),
unlisted: state.getIn(['compose', 'unlisted']),
private: state.getIn(['compose', 'private']),
fileDropDate: state.getIn(['compose', 'fileDropDate']),
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'])),
media_count: state.getIn(['compose', 'media_attachments']).size,
me: state.getIn(['compose', 'me'])
};
};
return mapStateToProps;
};
const mapDispatchToProps = function (dispatch) {
return {
onChange (text) {
dispatch(changeCompose(text));
},
onSubmit () {
dispatch(submitCompose());
},
onCancelReply () {
dispatch(cancelReplyCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId) {
dispatch(selectComposeSuggestion(position, token, accountId));
},
onChangeSensitivity (checked) {
dispatch(changeComposeSensitivity(checked));
},
onChangeVisibility (checked) {
dispatch(changeComposeVisibility(checked));
},
onChangeListability (checked) {
dispatch(changeComposeListability(checked));
}
}
};
export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);

View File

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

View File

@@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearchSuggestions,
fetchSearchSuggestions,
resetSearch
} from '../../../actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
suggestions: state.getIn(['search', 'suggestions']),
value: state.getIn(['search', 'value'])
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearchSuggestions());
},
onFetch (value) {
dispatch(fetchSearchSuggestions(value));
},
onReset () {
dispatch(resetSearch());
}
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

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

View File

@@ -0,0 +1,18 @@
import { connect } from 'react-redux';
import UploadButton from '../components/upload_button';
import { uploadCompose } from '../../../actions/compose';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
resetFileKey: state.getIn(['compose', 'resetFileKey'])
});
const mapDispatchToProps = dispatch => ({
onSelectFile (files) {
dispatch(uploadCompose(files));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);

View File

@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import UploadForm from '../components/upload_form';
import { undoUploadCompose } from '../../../actions/compose';
const mapStateToProps = (state, props) => ({
media: state.getIn(['compose', 'media_attachments']),
});
const mapDispatchToProps = dispatch => ({
onRemoveFile (media_id) {
dispatch(undoUploadCompose(media_id));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);

View File

@@ -1,36 +1,36 @@
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';
import Drawer from './components/drawer';
import ComposeFormContainer from './containers/compose_form_container';
import UploadFormContainer from './containers/upload_form_container';
import NavigationContainer from './containers/navigation_container';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import SearchContainer from './containers/search_container';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose';
const Compose = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired
dispatch: React.PropTypes.func.isRequired,
withHeader: React.PropTypes.bool
},
mixins: [PureRenderMixin],
componentDidMount () {
this.props.dispatch(fetchSuggestions());
this.props.dispatch(mountCompose());
},
componentWillUnmount () {
this.props.dispatch(unmountCompose());
},
render () {
return (
<Drawer>
<div style={{ flex: '1 1 auto' }}>
<NavigationContainer />
<ComposeFormContainer />
<UploadFormContainer />
</div>
<SuggestionsContainer />
<FollowFormContainer />
<Drawer withHeader={this.props.withHeader}>
<SearchContainer />
<NavigationContainer />
<ComposeFormContainer />
<UploadFormContainer />
</Drawer>
);
}

View File

@@ -0,0 +1,63 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
import Column from '../ui/components/column';
import StatusList from '../../components/status_list';
import ColumnBackButton from '../public_timeline/components/column_back_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
me: state.getIn(['meta', 'me'])
});
const Favourites = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
loaded: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired,
me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFavouritedStatuses());
},
handleScrollToBottom () {
this.props.dispatch(expandFavouritedStatuses());
},
render () {
const { statusIds, loaded, intl, me } = this.props;
if (!loaded) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='star' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButton />
<StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
</Column>
);
}
});
export default connect(mapStateToProps)(injectIntl(Favourites));

View File

@@ -0,0 +1,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 '../../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,61 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from '../../../components/permalink';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }
});
const outerStyle = {
padding: '14px 10px'
};
const panelStyle = {
background: '#2f3441',
display: 'flex',
flexDirection: 'row',
borderTop: '1px solid #363c4b',
borderBottom: '1px solid #363c4b',
padding: '10px 0'
};
const btnStyle = {
flex: '1 1 auto',
textAlign: 'center'
};
const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
const content = { __html: emojify(account.get('note')) };
return (
<div>
<div style={outerStyle}>
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={48} /></div>
<DisplayName account={account} />
</Permalink>
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
</div>
<div style={panelStyle}>
<div style={btnStyle}><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
<div style={btnStyle}><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
</div>
</div>
)
};
AccountAuthorize.propTypes = {
account: ImmutablePropTypes.map.isRequired,
onAuthorize: React.PropTypes.func.isRequired,
onReject: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
};
export default injectIntl(AccountAuthorize);

View File

@@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import AccountAuthorize from '../components/account_authorize';
import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id)
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { id }) => ({
onAuthorize (account) {
dispatch(authorizeFollowRequest(id));
},
onReject (account) {
dispatch(rejectFollowRequest(id));
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);

View File

@@ -0,0 +1,66 @@
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 { ScrollContainer } from 'react-router-scroll';
import Column from '../ui/components/column';
import AccountAuthorizeContainer from './containers/account_authorize_container';
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items'])
});
const FollowRequests = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFollowRequests());
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowRequests());
}
},
render () {
const { intl, accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='users' heading={intl.formatMessage(messages.heading)}>
<ScrollContainer scrollKey='follow_requests'>
<div className='scrollable' onScroll={this.handleScroll}>
{accountIds.map(id =>
<AccountAuthorizeContainer key={id} id={id} />
)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(injectIntl(FollowRequests));

View File

@@ -0,0 +1,64 @@
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,
expandFollowers
} 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), 'items'])
});
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)));
}
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return <LoadingIndicator />;
}
return (
<ScrollContainer scrollKey='followers'>
<div className='scrollable' onScroll={this.handleScroll}>
<div>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</div>
</ScrollContainer>
);
}
});
export default connect(mapStateToProps)(Followers);

View File

@@ -0,0 +1,64 @@
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,
expandFollowing
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../../containers/account_container';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items'])
});
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)));
}
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return <LoadingIndicator />;
}
return (
<ScrollContainer scrollKey='following'>
<div className='scrollable' onScroll={this.handleScroll}>
<div>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</div>
</ScrollContainer>
);
}
});
export default connect(mapStateToProps)(Following);

View File

@@ -0,0 +1,10 @@
import Column from '../ui/components/column';
import MissingIndicator from '../../components/missing_indicator';
const GenericNotFound = () => (
<Column>
<MissingIndicator />
</Column>
);
export default GenericNotFound;

View File

@@ -1,19 +1,55 @@
import Column from '../ui/components/column';
import Column from '../ui/components/column';
import ColumnLink from '../ui/components/column_link';
import { Link } from 'react-router';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
});
const GettingStarted = ({ intl, me }) => {
let followRequests = '';
if (me.get('locked')) {
followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />;
}
const GettingStarted = () => {
return (
<Column>
<div className='static-content'>
<h1>Getting started</h1>
<p>Mastodon is still in development and one of the lacking areas at the moment is user discovery.</p>
<p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p>
<p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p>
<p>The developer of this project can be followed as Gargron@mastodon.social</p>
<p>Also <Link to='/statuses/all' style={{ color: '#2b90d9', textDecoration: 'none' }}>check out the public timeline for a start</Link>!</p>
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
<div style={{ position: 'relative' }}>
<ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
{followRequests}
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='scrollable optionally-scrollable'>
<div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
</div>
</div>
</Column>
);
};
export default GettingStarted;
GettingStarted.propTypes = {
intl: React.PropTypes.object.isRequired,
me: ImmutablePropTypes.map.isRequired
};
export default connect(mapStateToProps)(injectIntl(GettingStarted));

View File

@@ -0,0 +1,80 @@
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';
import ColumnBackButton from '../public_timeline/components/column_back_button';
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', id));
this._subscribe(dispatch, id);
},
componentWillReceiveProps (nextProps) {
if (nextProps.params.id !== this.props.params.id) {
this.props.dispatch(refreshTimeline('tag', 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}>
<ColumnBackButton />
<StatusListContainer type='tag' id={id} />
</Column>
);
},
});
export default connect()(HashtagTimeline);

View File

@@ -0,0 +1,68 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnCollapsable from '../../../components/column_collapsable';
import SettingToggle from '../../notifications/components/setting_toggle';
import SettingText from './setting_text';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
});
const outerStyle = {
background: '#373b4a',
padding: '15px'
};
const sectionStyle = {
cursor: 'default',
display: 'block',
fontWeight: '500',
color: '#9baec8',
marginBottom: '10px'
};
const rowStyle = {
};
const ColumnSettings = React.createClass({
propTypes: {
settings: ImmutablePropTypes.map.isRequired,
onChange: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
render () {
const { settings, onChange, onSave, intl } = this.props;
return (
<ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}>
<div style={outerStyle}>
<span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
</div>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div style={rowStyle}>
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
</ColumnCollapsable>
);
}
});
export default injectIntl(ColumnSettings);

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