Compare commits

...

179 Commits

Author SHA1 Message Date
kibigo!
866e441df3 [WIP] Initial status work 2017-08-14 15:07:22 -07:00
Ondřej Hruška
4dc0ddc601 fix regression - collapse button not working 2017-08-07 22:46:24 +02:00
Ondřej Hruška
7a1ca8b0df Merge remote-tracking branch 'upstream/master' 2017-08-07 22:43:52 +02:00
Ondřej Hruška
b8791ae79b always put @handles on a new line. undo some needless changes from prev cmmt 2017-08-07 21:59:49 +02:00
Ondřej Hruška
b9a2ceca35 removed ellipsis from .display-name 2017-08-07 21:24:19 +02:00
kibigo!
70c5eccc12 Compatibility regex for user profiles 2017-08-06 15:10:06 -07:00
Ondřej Hruška
eb7fc34708 Merge pull request #124 from glitch-soc/data-avatar-of
all checks have failed woooooo \*merges*
2017-08-06 21:49:26 +02:00
Ondřej Hruška
91836d577e Add data-avatar-of="@..." to all user avatars 2017-08-06 21:24:13 +02:00
Ondřej Hruška
7de0fa698d Updated glitch files to use the new Avatar class correctly 2017-08-06 21:23:59 +02:00
Ondřej Hruška
811d895f7b Merged upstream PR #4526 2017-08-06 21:23:36 +02:00
Ondřej Hruška
7b42d14f45 fix bug with data attrib for boost in home TL 2017-08-04 22:38:58 +02:00
Ondřej Hruška
f34f33c19e Add data- attributes to statuses for userstyle selectors (#117)
* Add data- attributes to statuses for userstyle selectors

* use const and template string, replace reblog->boosted and favourite->favourited

* more template strings because sorin-sama said so
2017-08-04 22:11:46 +02:00
kibigo!
8b58153583 Fixed onClick column links 2017-08-01 13:46:52 -07:00
kibigo!
8150689b48 Merge upstream (#111) 2017-08-01 13:20:29 -07:00
Gô Shoemake
b61e3daf98 Multiple frontend support (#110)
* Initial multiple frontend support

* Removed unnecessary require()

* Moved styles/images out of common
2017-07-30 19:28:21 +02:00
Ondřej Hruška
6ff084dbbb Improved notifications cleaning UI with set operations (#109)
* added notification cleaning drawer

* bugfix

* fully implemented set operations for notif cleaning

* i18n for notif cleaning drawer & improved logic slightly. Also added a confirm dialog

* - notif dismiss "overlay" now shoves the notif aside to avoid overlap
- added focus ring to header buttons
- removed notif overlay entirely from DOM if mode is disabled

* removed comment

* CSS tuning - inconsistent division lines fix
2017-07-30 12:36:28 -04:00
David Yip
9aaf3218d2 Add commit_hash to instance presenter double (#107)
glitch-soc's about page grabs said value from InstancePresenter; the
double needs to emulate that.
2017-07-28 19:25:30 -04:00
Ondřej Hruška
cb69e35b3b Add visibility icon to Detailed status 2017-07-27 00:41:28 +02:00
Ondřej Hruška
e82021e0e6 Merge pull request #97 from glitch-soc/LESS_UGLY_LANDING_PAGE_TEXT_CSS
Landing page CSS fixes
2017-07-25 23:32:59 +02:00
Ondřej Hruška
8925731c98 Improve landing page CSS and text contrast 2017-07-25 23:30:31 +02:00
Ondřej Hruška
4c233b4f3a Add .fa-external-link to instance list link 2017-07-25 23:30:13 +02:00
Ondřej Hruška
b7cf758fbe Fix JS errors and add back commit hash 2017-07-25 21:54:12 +02:00
Ondřej Hruška
7e5691804d Merge git://github.com/tootsuite/mastodon into tootsuite-master 2017-07-25 21:36:22 +02:00
Ondřej Hruška
852acbd738 amend 3ba7c1e7 to actually do what I wanted to do *facepalm* 2017-07-22 22:32:03 +02:00
Ondřej Hruška
6913426e48 Merge pull request #92 from tootsuite/master
tiny upstream merge
2017-07-22 22:20:17 +02:00
Ondřej Hruška
3ba7c1e725 Textarea does not auto-expand on mobile anymore (autofocus only on desktop) 2017-07-22 21:48:56 +02:00
Ondřej Hruška
9b74a12045 Adjust margins for mobile with navbar-under 2017-07-22 20:41:21 +02:00
Ondřej Hruška
74a0cc6a11 Added settings toggle to move the navbar at the bottom in mobile view (#93) 2017-07-22 19:51:34 +02:00
beatrix-bitrot
984d2d4cb6 Merge that good fresh upstream shit 2017-07-22 01:16:53 +00:00
Ondřej Hruška
0244019ca1 Fixed horrible outline around notif clearing checkbox & moved the overlay to a more sr-friendly place 2017-07-21 21:12:43 +02:00
Ondřej Hruška
604654ccb4 New notification cleaning mode (#89)
This PR adds a new notification cleaning mode, super perfectly tuned for accessibility, and removes the previous notification cleaning functionality as it's now redundant.

* w.i.p. notif clearing mode

* Better CSS for selected notification and shorter text if Stretch is off

* wip for rebase ~

* all working in notif clearing mode, except the actual removal

* bulk delete route for piggo

* cleaning + refactor. endpoint gives 422 for some reason

* formatting

* use the right route

* fix broken destroy_multiple

* load more notifs after succ cleaning

* satisfy eslint

* Removed CSS for the old notif delete button

* Tabindex=0 is mandatory

In order to make it possible to tab to this element you must have tab index = 0. Removing this violates WCAG and makes it impossible to use the interface without good eyesight and a mouse. So nobody with certain mobility impairments, vision impairments, or brain injuries would be able to use this feature if you don't have tabindex=0

* Corrected aria-label

Previous label implied a different behavior from what actually happens

* aria role localization & made the overlay behave like a checkbox

* checkboxes css and better contrast

* color tuning for the notif overlay

* fanceh checkboxes etc and nice backgrounds

* SHUT UP TRAVIS
2017-07-21 20:33:16 +02:00
beatrix
0efd7e7406 disable about page timeline 2017-07-20 17:26:00 -04:00
beatrix
e7edb4d1ee Merge pull request #87 from tootsuite/master
merge upstream
2017-07-20 11:24:32 -04:00
kibigo!
d235224692 Restructured local settings internals 2017-07-19 20:41:28 -07:00
kibigo!
0a678cf377 Fix for stylesheet split 2017-07-18 11:28:52 -07:00
Surinna Curtis
7a77f7b3bb Add sourceRoot/includePaths to loaders
Use the settings modal as an example/testcase
2017-07-18 11:27:48 -07:00
Ondřej Hruška
df74e26baf Merge branch 'tootsuite-master' 2017-07-18 18:59:00 +02:00
Ondřej Hruška
d69fa9e1f4 Merge changes from upstream with the CSS reload fix 2017-07-18 18:58:47 +02:00
Ondřej Hruška
c727eae441 Updated from tootsuite 2017-07-17 20:03:57 +02:00
kibigo!
d0aad1ac85 Documentation and cleanup 2017-07-16 17:13:16 -07:00
kibigo!
21b04af524 Fixes lack of <tbody> in header metadata table 2017-07-16 16:49:12 -07:00
Ondřej Hruška
3ea02314b9 split added glitch locales from vanilla (#82)
* Locale script now accepts overrides and new keys from glitch/locales

* Revert glitchsoc changes to mastodon/locales to prevent future merge conflicts
2017-07-16 01:15:25 +02:00
kibigo!
4715161a93 FIXED STUFF FROM THE MERGE SORRY ;_; 2017-07-15 15:42:39 -07:00
kibigo!
144db8ea1d poke and visible docs url 2017-07-15 15:34:46 -07:00
kibigo!
bc4202d00b Ported updates from #64 2017-07-15 15:10:06 -07:00
kibigo!
09cfc079b0 Merge upstream (#81) 2017-07-15 14:33:15 -07:00
Ondřej Hruška
08d021916d Fixed issue #72 - bad css in report dialog 2017-07-15 16:45:39 +02:00
Ondřej Hruška
99f24ab0c7 Raise search results count to 10 for test
reference: https://mastodon.xyz/users/lx/updates/278054
2017-07-15 15:42:24 +02:00
Ondřej Hruška
3a526e2369 Fix broken letterboxing in media previews 2017-07-15 15:38:18 +02:00
Gô Shoemake
51e3ac2534 Added link to docs website 2017-07-14 09:42:50 -07:00
Ondřej Hruška
75aafc932e Added buttons and menu items to dismiss individual notifications (#76)
* Added DELETE verb for notifications

* Added notification dismiss button to status dropdown

* Added reveal-on-hover notif dismiss button, added FollowNotification component
2017-07-14 11:03:43 -04:00
kibigo!
6ce806f913 Fixed faulty import on notifs 2017-07-13 03:36:12 -07:00
kibigo!
35fda84ba8 Documentation pt. I 2017-07-13 03:26:08 -07:00
kibigo!
5770d461b2 Moved glitch containers and commented unused files 2017-07-13 02:40:16 -07:00
kibigo!
2e0645c26c Updated readme and contrib docs 2017-07-12 23:55:55 -07:00
Ondřej Hruška
66b1174d25 Fix CW auto-expanding if collapsed toots are disabled 2017-07-12 19:52:36 +02:00
kibigo!
183f993b01 Linting fixes 2017-07-12 02:36:40 -07:00
Surinna Curtis
e53fbb4a09 local-only/compose advanced options tweaks.
Squashed commit of the following:

commit b9877e37f72fdd8134936e1014033b07cb6c3671
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Wed Jul 12 00:50:10 2017 -0500

    account for the eye in the chars left count for local-only toots

commit 56ebfa96542e16daa1986cc45e07974801ee12dc
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Wed Jul 12 00:21:02 2017 -0500

    factor out an AdvancedOptionsToggle to avoid unnecessary re-renders

commit 04cec44ab8744e4e0f52da488c9ec24b1b1422ef
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Wed Jul 12 00:20:24 2017 -0500

    s/changeComposeAdvancedOption/toggleComposeAdvancedOption/g

commit af5815dee750d1aa8b797a9305e5ab3ce6774e3f
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Tue Jul 11 23:55:19 2017 -0500

    clicking anywhere on the whole advanced option toggles
2017-07-12 02:14:17 -07:00
kibigo!
79d898ae0a Merge upstream!! #64 <3 <3 2017-07-12 02:03:17 -07:00
kibigo!
bcf7ee48e9 Importing vanillin from upstream ;) ;) 2017-07-12 01:10:17 -07:00
kibigo!
297921fce5 Moved glitch files to their own location ;) 2017-07-12 01:02:51 -07:00
DJ Sundog
74eff5456c First lame pass at adding optional git commit hash display on /about/… (#67)
* First lame pass at adding optional git commit hash display on /about/more page.

Currently, this is implemented by checking for the existence of a file called CURRENT_RELEASE in the home directory of the user running Mastodon. If the file exists, its contents are added.

I've modified my update process to include the following before precompiling assets:

git log -1 | head -n 1 | cut -d " " -f2 > ~/CURRENT_RELEASE

That puts the current commit hash into the file ~/CURRENT_RELEASE, but you figured that out because you're a smart cookie.

As I am quite sure this is a horrible methodology for implementing this, I look forward to any improvements you have to offer!

* Updated to handle instances that share a user - the CURRENT_RELEASE file now lives in the instance's base directory.

This also requires modifying the update hook to `git log -1 | head -n 1 | cut -d " " -f2 > CURRENT_RELEASE`
2017-07-11 20:32:16 -04:00
Ondřej Hruška
60d27b4302 Fixed #66 disabling collapse collapses all toots 2017-07-11 20:48:31 +02:00
Ondřej Hruška
08d19778d5 Fix extra clickable spaces for narrow screen 2017-07-11 17:53:50 +02:00
Ondřej Hruška
9f7a5aac1e Toot context menu is now centered 2017-07-10 10:21:20 -04:00
Ondřej Hruška
945c5812d3 Added extra clickable area in status gutter 2017-07-10 09:29:20 -04:00
Ondřej Hruška
667b567606 Make avatar in compose navbar hover-to-play 2017-07-10 10:26:46 +02:00
Ondřej Hruška
8e2b1f79e4 Small indent fix in components.scss 2017-07-09 14:00:13 +02:00
Ondřej Hruška
345290a905 Avatar in the detail column no longer autoplays 2017-07-09 07:54:33 -04:00
Ondřej Hruška
2fb78fefc6 Fix fullwidth media CSS bugs with NSFW video, and bad spoiler margin on static pages (#60)
* Fix fullwidth style not applied to NSFW video correctly

* Fix botched video .media-spoiler margin on static pages
2017-07-09 09:07:14 +02:00
Ondřej Hruška
dc2b8bdecd Added a toggle for full-width media previews 2017-07-08 06:46:12 -04:00
Ondřej Hruška
e3c2183c12 New design for visibility icons 2017-07-07 23:07:16 -04:00
kibigo!
86f8df7903 Fixed avis on static pages 2017-07-07 15:56:05 -07:00
Ondřej Hruška
d41cec90cf Added toot visibility icons and removed Boost btn changing icon 2017-07-07 06:31:11 -04:00
Ondřej Hruška
7859e6ad45 Fix back button to never go to a different website using history len check 2017-07-07 05:59:15 -04:00
Surinna Curtis
3464bb30f8 replies to local-only toots default to local-only, and fix some regex bugs 2017-07-06 22:30:51 -05:00
Ondřej Hruška
d87d70e89a Fixed js type error in advanced_options_dropdown.js 2017-07-06 14:46:45 -07:00
kibigo!
0c7ee5c792 Fixed non-status notification styling 2017-07-05 19:26:19 -07:00
kibigo!
bba75c15f1 Statuses redux!
- Better unified reblogs, statuses, and notifications
- Polished up collapsed toots greatly
- Apologies to bea if this makes everything more difficult
2017-07-05 18:51:23 -07:00
Ondřej Hruška
4cbbea5881 Improved CSS for drawer to restore original looks (full height) 2017-07-05 07:21:36 -04:00
Ondřej Hruška
167c392efd Fix drawer clipping dropdowns 2017-07-05 07:21:36 -04:00
Surinna Curtis
193f354d3e a real default for advanced options long description 2017-07-05 00:09:20 -05:00
Surinna Curtis
6b67b91eb1 satisfy eslint 2017-07-04 21:33:53 -07:00
Surinna Curtis
6b77424660 some adjustments to open/active for advanced options dropdown 2017-07-04 21:33:53 -07:00
Surinna Curtis
301c185878 highlight … button if any options enabled 2017-07-04 21:33:53 -07:00
Surinna Curtis
cb7f54891f Revert "change active/hover display on advanced options"
This reverts commit ade773cb0a.
2017-07-04 21:33:53 -07:00
Surinna Curtis
f6ce1a9592 toggles for advanced options 2017-07-04 21:33:53 -07:00
Surinna Curtis
aee64b996c change active/hover display on advanced options 2017-07-04 21:33:53 -07:00
Surinna Curtis
0c71c0ccc8 reset advanced options when appropriate 2017-07-04 21:33:53 -07:00
Surinna Curtis
49e82c1e0f add an eye when submitting a toot with do_not_federate enabled 2017-07-04 21:33:53 -07:00
Surinna Curtis
556cede00f Local-only option and dropdown all working 2017-07-04 21:33:53 -07:00
Surinna Curtis
b73ee36949 Reduce advanced options dropdown width 2017-07-04 21:33:53 -07:00
Surinna Curtis
dd49c10cdb Further improvements to dropdown html 2017-07-04 21:33:53 -07:00
Surinna Curtis
85d5249479 The beginnings of an advanced options dropdown 2017-07-04 21:33:53 -07:00
Surinna Curtis
ff9f2088f7 Move layout override into app settings modal
Squashed commit of the following:

commit 3842f879865818a3299f8283f8ed1b43c5566500
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 19:57:39 2017 -0500

    Fix some style issues

commit 08628a0234392ecac90e869a1272f429de0b6db2
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 19:35:22 2017 -0500

    Improved styling and layout of select app settings

commit 1787a7c20f2bf7101c6d6830450564178314a737
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 17:13:53 2017 -0500

    remove the layout selector ui from the drawer

commit 7d93b180b1e615e2c36210ad6f119fe80a2778d8
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 17:01:51 2017 -0500

    Add layout setting to app settings modal
2017-07-03 21:51:00 -05:00
adbelle
15227c713d Remove color lightening for search results section
This will cause it to match the shading of the container behind it.
2017-06-30 18:38:36 -04:00
adbelle
30736f4886 Add opaque background to search results section 2017-06-30 18:38:36 -04:00
kibigo!
c58877862d createBio function added 2017-06-30 15:03:31 -07:00
beatrix
0e310f1ee3 put travis thing in readme.md 2017-06-30 12:36:10 -04:00
beatrix-bitrot
7dd4d9de96 try fix failing controller spec caused by long bios 2017-06-30 16:27:52 +00:00
kibigo!
46f83bb28b Styling fixes to media spoilers 2017-06-30 02:56:19 -07:00
kibigo!
ec2daae71c Media display improvements
- built in fullwidth styling
 - letterbox settings toggle
 - media no longer counts towards height when making toot-collapsing
measurements
2017-06-30 02:18:25 -07:00
kibigo!
b525caf40a Fixing an aspect of #32 which had previously escaped my attention 2017-06-29 23:39:57 -07:00
kibigo!
fc65b691df Images now behind CWs on static pages 2017-06-29 22:59:28 -07:00
kibigo!
651c3d643c Images behind CWs in detailed statuses too 2017-06-29 22:48:22 -07:00
kibigo!
cc4cba8afd Improvements to image icon appearance on SHOW MORE 2017-06-29 01:43:15 -07:00
kibigo!
99889ea57d Tiny Status improvements
- Media toots are no longer auto-collapsed if the media is behind a CW
- Display names no longer appear clickable when a toot is collapsed
- Fixed #36 by adding reduplicating the media icon inside the SHOW
MORE/LESS
2017-06-28 23:23:05 -07:00
kibigo!
19690d3e33 Fixes #32 bug with revealing sensitive media 2017-06-28 22:57:30 -07:00
kibigo!
0b371da971 Fixed improper centering of columns-area 2017-06-28 22:18:22 -07:00
kibigo!
2d8ebdcc72 Initial store for local settings is now Immutable all the way down 2017-06-28 22:11:21 -07:00
kibigo!
595c6de32c Added App Setttings Modal 2017-06-28 22:00:54 -07:00
kibigo!
6cbbdc805f Fixed Regexp.new syntax 2017-06-28 15:16:13 -07:00
kibigo!
7b1d233f4f Linear gradient no longer extends under metadata on account pages 2017-06-28 01:10:28 -07:00
kibigo!
03f9648377 Improvements to static metadata styling, especially for mobile 2017-06-28 00:57:32 -07:00
kibigo!
6107e95404 Backend YAML Processing + Profile Metadata on Static Pages 2017-06-28 00:27:44 -07:00
kibigo!
36805a39db Moved reblog wrapper outside of <Status> 2017-06-27 18:34:30 -07:00
kibigo!
ab4632a41e Allow line-breaks in bio metadata 2017-06-27 16:20:35 -07:00
beatrix-bitrot
ddafde942c Merge remote-tracking branch 'upstream/master' 2017-06-27 20:46:13 +00:00
kibigo!
e6300de142 Profile metadata improvements 2017-06-27 05:48:26 -07:00
Surinna Curtis
a6f5111c79 Eyes with variation-selector-16 now also do local-only 2017-06-26 20:51:11 -05:00
kibigo!
59503a88ae Disable account linking on collapsed toots too 2017-06-26 15:41:09 -07:00
kibigo!
5df7bc3a8b Disable links on collapsed toots 2017-06-26 15:22:03 -07:00
kibigo!
c806fef865 Notifications collapsed by default 2017-06-26 14:28:43 -07:00
beatrix-bitrot
49ba78d6f8 fix account spec broken by change to 500char bios 2017-06-26 20:55:44 +00:00
Surinna Curtis
7b53d4bbca Bio length on client side mostly doesn't count metadata 2017-06-26 09:34:31 -05:00
Surinna Curtis
4f36aad6e8 don't count bio metadata against bio length on server 2017-06-26 09:34:31 -05:00
kibigo!
56ca33a6d3 Time needs to be just a little wider to accomodate minutes 2017-06-25 19:38:58 -07:00
kibigo!
aeff898137 We want wrapping here, sorry 😥 2017-06-25 19:26:59 -07:00
kibigo!
b323e00bf3 Merge branch 'master' of https://github.com/glitch-soc/mastodon 2017-06-25 19:19:52 -07:00
kibigo!
a520b118e4 Expand toot by clicking on body [fixed!] 2017-06-25 19:19:45 -07:00
beatrix-bitrot
93fc8aa14c keyword muting and local only tooting WIP 2017-06-26 02:19:21 +00:00
beatrix-bitrot
c0a665865e update bio length to 500 2017-06-26 02:18:52 +00:00
kibigo!
38a1299975 Stick images inside of CWs 2017-06-25 19:15:03 -07:00
kibigo!
96e1f75679 Fixes to overflows wrt drawer/column 2017-06-25 18:05:28 -07:00
kibigo!
3a99552f0c Fixed bad drawer custom.css 2017-06-25 17:55:48 -07:00
kibigo!
22cc5c0dec Improvements to action bar positioning 2017-06-25 17:35:27 -07:00
Surinna Curtis
efa425206c Move status action bar left
This also resolves the issues where sometimes the timestamps wrap and also sits unpleasantly close to the … button.
2017-06-25 18:24:50 -05:00
kibigo!
e60f27d649 Clicking on a collapsed toot just uncollapses it. 2017-06-25 16:09:32 -07:00
kibigo!
6a50e73089 Auto-uncollapse when expanding CW 2017-06-25 15:52:55 -07:00
kibigo!
b1f9892e63 [custom.scss] More media height fixes 2017-06-25 15:29:23 -07:00
kibigo!
d6e3918d92 Disables CW [Show More/Less] links in collapsed toots 2017-06-25 15:25:19 -07:00
kibigo!
6909bbdc9e [custom.scss] max-height of media now a fixed value 2017-06-25 14:44:52 -07:00
kibigo!
ddc6b85912 Color adjustment to media icons 2017-06-25 14:25:35 -07:00
kibigo!
4bc237fcfe Adds media icons to toots 2017-06-25 14:22:11 -07:00
kibigo!
efacfec3ed Media previews for collapsed toots 2017-06-25 13:57:52 -07:00
kibigo!
8ea779e59a Patching rebase errors 2017-06-25 12:51:31 -07:00
Shel Raphen
7eda83a36a Glitchsocification 2017-06-25 19:21:51 +00:00
kibigo!
af178d0ba6 Removed no-longer-necessary custom style 2017-06-24 21:28:30 -07:00
Gô Shoemake
e4326b3f12 Merge pull request #26 from ekiru/feature/manual-column-layout-setting
User-controlled multi-column/single-column overrides
2017-06-24 20:44:43 -07:00
kibigo!
b8a5052d53 Better style handling at small sizes 2017-06-24 20:36:19 -07:00
kibigo!
7427680e75 Allowed little media rules 2017-06-24 20:14:58 -07:00
kibigo!
ca0d30c04b OKAY THIS WORKS THIS WORKS 2017-06-24 20:04:46 -07:00
kibigo!
da05cde721 Better settings handling with localSettings (new!) 2017-06-24 19:56:37 -07:00
kibigo!
4c37f629bc Don't change layout of static pages 2017-06-24 18:30:59 -07:00
kibigo!
ddba5d3b8c Use Redux store to keep track of layout 2017-06-24 18:30:30 -07:00
Surinna Curtis
ceb545c080 Pass in correct "singleColumn" prop value when auto-columns is not used. 2017-06-24 15:29:46 -05:00
Surinna Curtis
a70468aa56 Support overriding media queries for deciding between single-column/multi-column layouts with a class 2017-06-24 15:29:46 -05:00
beatrix-bitrot
8b23bf7cbd clean up old avatar class 2017-06-24 03:51:01 +00:00
Matthew Walsh
f1a60d4b81 Unified avatar styling
Avatars now have consistent styling across all pages; border radius can be adjusted with a SASS variable ($ui-avatar-border-size)
2017-06-24 03:03:27 +00:00
kibigo!
2513d92c54 Un-hide dropdown menu ;P 2017-06-23 19:39:44 -07:00
kibigo!
414dfb3955 ESLint improvements for Profile Metadata 2017-06-23 18:43:30 -07:00
Gô Shoemake
67adbcc60c Reblog support for collapsed toots 2017-06-23 18:23:26 -07:00
beatrix-bitrot
453b9c6e7e missing punctuation 2017-06-23 22:01:04 +00:00
beatrix
d9b9bb8c5e glitch the getting started image 2017-06-23 21:50:45 +00:00
kibigo!
40ecbfd4a9 Very minor styling improvements to toot-collapsing 2017-06-23 21:50:45 +00:00
kibigo!
4fe45dda9a Updates height upon collapsing 2017-06-23 21:50:45 +00:00
kibigo!
4bd7482a7a Minor collapsing button improvements~ 2017-06-23 21:50:45 +00:00
kibigo!
93c52301ad Collapsable toots [1/??] 2017-06-23 21:50:45 +00:00
kibigo!
0d3ec19e89 Profile Metadata HACK 😈 2017-06-23 21:45:14 +00:00
Go Shoemake
62a75891ab Fixes drawer so stuff doesn't overflow 2017-06-23 21:45:14 +00:00
Charlotte Fields
b27842dc70 cybre cleanup 2017-06-23 21:45:14 +00:00
Chronister
39b6b37b74 cybrespace to 1.4.2 2017-06-23 21:45:14 +00:00
Chronister
65528fc54e All cybrespace changes through 5/28 2017-06-23 21:45:14 +00:00
Charlotte Fields
382572c213 adding cybre changes 2017-06-23 21:45:14 +00:00
beatrix-bitrot
9bc593d675 update local modifications for cors and cp 2017-06-23 21:45:14 +00:00
beatrix-bitrot
09f7ad3614 silly readme update to test automated deploys 2017-06-23 21:45:14 +00:00
beatrix-bitrot
7c2ea42cd5 update README.md 2017-06-23 21:45:14 +00:00
beatrix
ea785d0baf Update README.md 2017-06-23 21:45:14 +00:00
Beatrix Bitrot
a337c5dbe5 CORS tweaks 2017-06-23 21:45:14 +00:00
171 changed files with 8537 additions and 464 deletions

View File

@@ -1,3 +1,36 @@
# Contributing to Mastodon Glitch Edition #
Thank you for your interest in contributing to the `glitch-soc` project!
Here are some guidelines, and ways you can help.
> (This document is a bit of a work-in-progress, so please bear with us.
> If you don't see what you're looking for here, please don't hesitate to reach out!)
## Planning ##
Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects.
We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler.
## Documentation ##
The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
Right now, we've mostly focused on the features that make this fork different from upstream in some manner.
Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code.
## Frontend Development ##
Check out [the documentation here](https://glitch-soc.github.io/docs/contributing/frontend/) for more information.
## Backend Development ##
See the guidelines below.
- - -
You should also try to follow the guidelines set out in the original `CONTRIBUTING.md` from `tootsuite/mastodon`, reproduced below.
<blockquote>
CONTRIBUTING
============
@@ -49,3 +82,5 @@ It is expected that you have a working development environment set up (see back-
* If you are introducing new strings, they must be using localization methods
If the JavaScript or CSS assets won't compile due to a syntax error, it's a good sign that the pull request isn't ready for submission yet.
</blockquote>

View File

@@ -1,70 +1,10 @@
![Mastodon](https://i.imgur.com/NhZc40l.png)
========
# Mastodon Glitch Edition #
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
> Now with automated deploys!
[travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
[![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon)
Mastodon is a free, open-source social network server. A decentralized solution 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.
So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it?
An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)).
Click on the screenshot to watch a demo of the UI:
[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
The project focus is a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
[patreon]: https://www.patreon.com/user?u=619786
## Resources
- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
## Features
- **Fully interoperable with GNU social and any OStatus platform**
Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network
- **Real-time timeline updates**
See the updates of people you're following appear in real-time in the UI via WebSockets
- **Federated thread resolving**
If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
- **Media attachments like images and WebM**
Upload and view images and WebM videos attached to the updates
- **OAuth2 and a straightforward REST API**
Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple
- **Background processing for long-running tasks**
Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are
- **Deployable via Docker**
You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
## Development
Please follow the [development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md) from the documentation repository.
## Deployment
There are guides in the documentation repository for [deploying on various platforms](https://github.com/tootsuite/documentation#running-mastodon).
## 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. [Here are the guidelines for code contributions](CONTRIBUTING.md)
**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)
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).

View File

@@ -24,11 +24,20 @@ class Api::V1::NotificationsController < Api::BaseController
render_empty
end
def destroy
dismiss
end
def dismiss
current_account.notifications.find_by!(id: params[:id]).destroy!
render_empty
end
def destroy_multiple
current_account.notifications.where(id: params[:ids]).destroy_all
render_empty
end
private
def load_notifications

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::SearchController < Api::BaseController
RESULTS_LIMIT = 5
RESULTS_LIMIT = 10
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!

View File

@@ -6,6 +6,7 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
@frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon'
end
private

View File

@@ -0,0 +1,93 @@
/*
`actions/local_settings`
========================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
This file provides our Redux actions related to local settings. It
consists of the following:
- __`changesLocalSetting(key, value)` :__
Changes the local setting with the given `key` to the given
`value`. `key` **MUST** be an array of strings, as required by
`Immutable.Map.prototype.getIn()`.
- __`saveLocalSettings()` :__
Saves the local settings to `localStorage` as a JSON object. We
shouldn't ever need to call this ourselves.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Constants:
----------
We provide the following constants:
- __`LOCAL_SETTING_CHANGE` :__
This string constant is used to dispatch a setting change to our
reducer in `reducers/local_settings`, where the setting is
actually changed.
*/
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`changeLocalSetting(key, value)`:
---------------------------------
Changes the local setting with the given `key` to the given `value`.
`key` **MUST** be an array of strings, as required by
`Immutable.Map.prototype.getIn()`.
To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our
reducer in `reducers/local_settings`.
*/
export function changeLocalSetting(key, value) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_CHANGE,
key,
value,
});
dispatch(saveLocalSettings());
};
};
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`saveLocalSettings()`:
----------------------
Saves the local settings to `localStorage` as a JSON object.
`changeLocalSetting()` calls this whenever it changes a setting. We
shouldn't ever need to call this ourselves.
> __TODO :__
> Right now `saveLocalSettings()` doesn't keep track of which user
> is currently signed in, but it might be better to give each user
> their *own* local settings.
*/
export function saveLocalSettings() {
return (_, getState) => {
const localSettings = getState().get('local_settings').toJS();
localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
};
};

View File

@@ -0,0 +1,237 @@
/*
`<AccountHeader>`
=================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. We've expanded it in order to handle user bio
frontmatter.
The `<AccountHeader>` component provides the header for account
timelines. It is a fairly simple component which mostly just consists
of a `render()` method.
__Props:__
- __`account` (`ImmutablePropTypes.map`) :__
The account to render a header for.
- __`me` (`PropTypes.number.isRequired`) :__
The id of the currently-signed-in account.
- __`onFollow` (`PropTypes.func.isRequired`) :__
The function to call when the user clicks the "follow" button.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar';
// Our imports //
import { processBio } from '../../util/bio_metadata';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. In our case, these are the `unfollow`, `follow`, and
`requested` messages used in the `title` of our buttons.
*/
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
@injectIntl
export default class AccountHeader extends ImmutablePureComponent {
static propTypes = {
account : ImmutablePropTypes.map,
me : PropTypes.number.isRequired,
onFollow : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
/*
### `render()`
The `render()` function is used to render our component.
*/
render () {
const { account, me, intl } = this.props;
/*
If no `account` is provided, then we can't render a header. Otherwise,
we get the `displayName` for the account, if available. If it's blank,
then we set the `displayName` to just be the `username` of the account.
*/
if (!account) {
return null;
}
let displayName = account.get('display_name');
let info = '';
let actionBtn = '';
let following = false;
if (displayName.length === 0) {
displayName = account.get('username');
}
/*
Next, we handle the account relationships. If the account follows the
user, then we add an `info` message. If the user has requested a
follow, then we disable the `actionBtn` and display an hourglass.
Otherwise, if the account isn't blocked, we set the `actionBtn` to the
appropriate icon.
*/
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'followed_by'])) {
info = (
<span className='account--follows-info'>
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
</span>
);
}
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
following = account.getIn(['relationship', 'following']);
actionBtn = (
<div className='account--action-button'>
<IconButton
size={26}
icon={following ? 'user-times' : 'user-plus'}
active={following}
title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
onClick={this.props.onFollow}
/>
</div>
);
}
}
/*
`displayNameHTML` processes the `displayName` and prepares it for
insertion into the document. Meanwhile, we extract the `text` and
`metadata` from our account's `note` using `processBio()`.
*/
const displayNameHTML = {
__html : emojify(escapeTextContentForBrowser(displayName)),
};
const { text, metadata } = processBio(account.get('note'));
/*
Here, we render our component using all the things we've defined above.
*/
return (
<div className='account__header__wrapper'>
<div
className='account__header'
style={{ backgroundImage: `url(${account.get('header')})` }}
>
<div>
<a href={account.get('url')} target='_blank' rel='noopener'>
<span className='account__header__avatar'>
<Avatar account={account} size={90} />
</span>
<span
className='account__header__display-name'
dangerouslySetInnerHTML={displayNameHTML}
/>
</a>
<span className='account__header__username'>
@{account.get('acct')}
{account.get('locked') ? <i className='fa fa-lock' /> : null}
</span>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
{info}
{actionBtn}
</div>
</div>
{metadata.length && (
<table className='account__metadata'>
<tbody>
{(() => {
let data = [];
for (let i = 0; i < metadata.length; i++) {
data.push(
<tr key={i}>
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
</tr>
);
}
return data;
})()}
</tbody>
</table>
) || null}
</div>
);
}
}

View File

@@ -0,0 +1,113 @@
// <CommonAvatar>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/avatar
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonAvatar extends React.PureComponent {
// Props and state.
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
circular: PropTypes.bool,
className: PropTypes.string,
comrade: ImmutablePropTypes.map,
}
state = {
hovering: false,
}
// Starts or stops animation on hover.
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
}
// Renders the component.
render () {
const {
handleMouseEnter,
handleMouseLeave,
} = this;
const {
account,
animate,
circular,
className,
comrade,
...others
} = this.props;
const { hovering } = this.state;
const computedClass = classNames('glitch', 'glitch__common__avatar', {
_circular: circular,
}, className);
// We store the image srcs here for later.
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
const comradeSrc = comrade ? comrade.get('avatar') : null;
const comradeStaticSrc = comrade ? comrade.get('avatar_static') : null;
// Avatars are a straightforward div with image(s) inside.
return comrade ? (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
<img
className='avatar\main'
src={hovering || animate ? src : staticSrc}
alt=''
/>
<img
className='avatar\comrade'
src={hovering || animate ? comradeSrc : comradeStaticSrc}
alt=''
/>
</div>
) : (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
<img
className='avatar\solo'
src={hovering || animate ? src : staticSrc}
alt=''
/>
</div>
);
}
}

View File

@@ -0,0 +1,41 @@
@import 'variables';
.glitch.glitch__common__avatar {
display: inline-block;
position: relative;
& > img {
display: block;
position: static;
margin: 0;
border-radius: $ui-avatar-border-size;
width: 100%;
height: 100%;
&.avatar\\comrade {
position: absolute;
right: 0;
bottom: 0;
width: 50%;
height: 50%;
}
&.avatar\\main {
margin: 0 30% 30% 0;
width: 70%;
height: 70%;
}
}
&._circular {
& > img {
transition: border-radius ($glitch-animation-speed * .3s);
}
&:not(:hover) {
& > img {
border-radius: 50%;
}
}
}
}

View File

@@ -0,0 +1,146 @@
// <CommonButton>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/button
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Our imports.
import CommonLink from 'glitch/components/common/link';
import CommonIcon from 'glitch/components/common/icon';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonButton extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
animate: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string,
disabled: PropTypes.bool,
href: PropTypes.string,
icon: PropTypes.string,
onClick: PropTypes.func,
showTitle: PropTypes.bool,
title: PropTypes.string,
}
state = {
loaded: false,
}
// The `loaded` state property activates our animations. We wait
// until an activation change in order to prevent unsightly
// animations when the component first mounts.
componentWillReceiveProps (nextProps) {
const { active } = this.props;
// The double "not"s here cast both arguments to booleans.
if (!nextProps.active !== !active) this.setState({ loaded: true });
}
handleClick = (e) => {
const { onClick } = this.props;
if (!onClick) return;
onClick(e);
e.preventDefault();
}
// Rendering the component.
render () {
const { handleClick } = this;
const {
active,
animate,
children,
className,
disabled,
href,
icon,
onClick,
showTitle,
title,
...others
} = this.props;
const { loaded } = this.state;
const computedClass = classNames('glitch', 'glitch__common__button', className, {
_active: active && !href, // Links can't be active
_animated: animate && loaded,
_disabled: disabled,
_link: href,
_star: icon === 'star',
'_with-text': children || title && showTitle,
});
let conditionalProps = {};
// If href is provided, we render a link.
if (href) {
if (!disabled && href) conditionalProps.href = href;
if (title && !showTitle) {
if (!children) conditionalProps.title = title;
else conditionalProps['aria-label'] = title;
}
if (onClick) {
if (!disabled) conditionalProps.onClick = handleClick;
else conditionalProps['aria-disabled'] = true;
conditionalProps.role = 'button';
conditionalProps.tabIndex = 0;
}
return (
<CommonLink
className={computedClass}
{...conditionalProps}
{...others}
>
{children}
{title && showTitle ? <span className='button\title'>{title}</span> : null}
<CommonIcon name={icon} className='button\icon' />
</CommonLink>
);
// Otherwise, we render a button.
} else {
if (active !== void 0) conditionalProps['aria-pressed'] = active;
if (title && !showTitle) {
if (!children) conditionalProps.title = title;
else conditionalProps['aria-label'] = title;
}
if (onClick && !disabled) {
conditionalProps.onClick = handleClick;
}
return (
<button
className={computedClass}
{...conditionalProps}
disabled={disabled}
{...others}
tabIndex='0'
type='button'
>
{children}
{title && showTitle ? <span className='button\title'>{title}</span> : null}
<CommonIcon name={icon} className='button\icon' />
</button>
);
}
};
}

View File

@@ -0,0 +1,134 @@
@import 'variables';
.glitch.glitch__common__button {
display: inline-block;
border: none;
padding: 0;
color: $ui-base-lighter-color;
background: transparent;
outline: thin transparent dotted;
font-size: inherit;
text-decoration: none;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
&._animated .button\\icon {
animation-name: glitch__common__button__deactivate;
animation-duration: .9s;
animation-timing-function: ease-in-out;
@keyframes glitch__common__button__deactivate {
from {
transform: rotate(360deg);
}
57% {
transform: rotate(-60deg);
}
86% {
transform: rotate(30deg);
}
to {
transform: rotate(0deg);
}
}
}
&._active {
.button\\icon {
color: $ui-highlight-color;
}
&._animated .button\\icon {
animation-name: glitch__common__button__activate;
@keyframes glitch__common__button__activate {
from {
transform: rotate(0deg);
}
57% {
transform: rotate(420deg); // Blazin' 😎
}
86% {
transform: rotate(330deg);
}
to {
transform: rotate(360deg);
}
}
}
/*
The special `._star` class is given to buttons which have a star
icon (see JS). When they are active, we give them a gold star ⭐️.
*/
&._star .button\\icon {
color: $gold-star;
}
}
/*
For links, we consider them disabled if they don't have an `href`
attribute (see JS).
*/
&._disabled {
opacity: $glitch-disabled-opacity;
cursor: default;
}
/*
This is confusing becuase of the names, but the `color .3 ease-out`
transition is actually used when easing *in* to a hovering/active/
focusing state, and the default transition is used when leaving. Our
buttons are a little slower to glow than they are to fade.
*/
&:active,
&:focus,
&:hover {
color: $glitch-lighter-color;
transition: color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
}
&:focus {
outline-color: currentColor;
}
/*
Buttons with text have a number of different styling rules and an
overall different appearance.
*/
&._with-text {
display: inline-block;
border: none;
border-radius: .35em;
padding: 0 .5em;
color: $glitch-texture-color;
background: $ui-base-lighter-color;
font-size: .75em;
font-weight: inherit;
text-transform: uppercase;
line-height: 1.6;
cursor: pointer;
vertical-align: baseline;
transition: background-color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
.button\\icon {
display: inline-block;
font-size: 1.25em;
vertical-align: -.1em;
}
& > *:not(:first-child) {
margin: 0 0 0 .4em;
border-left: 1px solid currentColor;
padding: 0 0 0 .3em;
}
&:active,
&:hover,
&:focus {
color: $glitch-texture-color;
background: $glitch-lighter-color;
transition: background-color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
}
}
}

View File

@@ -0,0 +1,59 @@
// <CommonIcon>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/icon
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const CommonIcon = ({
className,
name,
proportional,
title,
...others
}) => name ? (
<span
className={classNames('glitch', 'glitch__common__icon', className)}
{...others}
>
<span
aria-hidden
className={`fa ${proportional ? '' : 'fa-fw'} fa-${name} icon\fa`}
{...(title ? { title } : {})}
/>
{title ? (
<span className='_for-screenreader'>{title}</span>
) : null}
</span>
) : null;
// Props.
CommonIcon.propTypes = {
className: PropTypes.string,
name: PropTypes.string,
proportional: PropTypes.bool,
title: PropTypes.string,
};
// Export.
export default CommonIcon;

View File

@@ -0,0 +1,14 @@
@import 'variables';
.glitch.glitch__common__icon {
display: inline-block;
._for-screenreader {
position: absolute;
margin: -1px -1px;
width: 1px;
height: 1px;
clip: rect(0, 0, 0, 0);
overflow: hidden;
}
}

View File

@@ -0,0 +1,74 @@
// <CommonLink>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/link
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonLink extends React.PureComponent {
// Props.
static propTypes = {
children: PropTypes.node,
className: PropTypes.string,
destination: PropTypes.string,
history: PropTypes.object,
href: PropTypes.string,
};
// We only reroute the link if it is an unadorned click, we have
// access to the router, and there is somewhere to reroute it *to*.
handleClick = (e) => {
const { destination, history } = this.props;
if (!history || !destination || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
history.push(destination);
e.preventDefault();
}
// Rendering.
render () {
const { handleClick } = this;
const { children, className, destination, history, href, ...others } = this.props;
const computedClass = classNames('glitch', 'glitch__common__link', className);
const conditionalProps = {};
if (href) {
conditionalProps.href = href;
conditionalProps.onClick = handleClick;
} else if (destination) {
conditionalProps.onClick = handleClick;
conditionalProps.role = 'link';
conditionalProps.tabIndex = 0;
} else conditionalProps.role = 'presentation';
return (
<a
className={computedClass}
{...conditionalProps}
{...others}
rel='noopener'
target='_blank'
>{children}</a>
);
}
}

View File

@@ -0,0 +1,11 @@
@import 'variables';
/*
Most link styling happens elsewhere but we disable text-decoration
here.
*/
.glitch.glitch__common__link {
display: inline;
color: $ui-secondary-color;
text-decoration: none;
}

View File

@@ -0,0 +1,49 @@
// <CommonSeparator>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/separator
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const CommonSeparator = ({
className,
visible,
...others
}) => visible ? (
<span
className={
classNames('glitch', 'glitch__common__separator', className)
}
{...others}
role='separator'
/> // Contents provided via CSS.
) : null;
// Props.
CommonSeparator.propTypes = {
className: PropTypes.string,
visible: PropTypes.bool,
};
// Export.
export default CommonSeparator;

View File

@@ -0,0 +1,15 @@
@import 'variables';
/*
The default contents for a separator is an interpunct, surrounded by
spaces. However, this can be changed using CSS selectors.
*/
.glitch.glitch__common__separator {
display: inline-block;
&::after {
display: inline-block;
padding: 0 .3em;
content: "·";
}
}

View File

@@ -0,0 +1,66 @@
/*
`<ComposeAdvancedOptionsContainer>`
===================================
This container connects `<ComposeAdvancedOptions>` to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { toggleComposeAdvancedOption } from '../../../../mastodon/actions/compose';
// Our imports //
import ComposeAdvancedOptions from '.';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. The only property we care about is
`compose.advanced_options`.
*/
const mapStateToProps = state => ({
values: state.getIn(['compose', 'advanced_options']),
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We just need to provide a dispatch for
when an advanced option toggle changes.
*/
const mapDispatchToProps = dispatch => ({
onChange (option) {
dispatch(toggleComposeAdvancedOption(option));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);

View File

@@ -0,0 +1,241 @@
/*
`<ComposeAdvancedOptions>`
==========================
> For more information on the contents of this file, please contact:
>
> - surinna [@srn@dev.glitch.social]
This adds an advanced options dropdown to the toot compose box, for
toggles that don't necessarily fit elsewhere.
__Props:__
- __`values` (`ImmutablePropTypes.contains(…).isRequired`) :__
An Immutable map with the following values:
- __`do_not_federate` (`PropTypes.bool.isRequired`) :__
Specifies whether or not to federate the status.
- __`onChange` (`PropTypes.func.isRequired`) :__
The function to call when a toggle is changed. We pass this from
our container to the toggle.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
__State:__
- __`open` :__
This tells whether the dropdown is currently open or closed.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../../mastodon/components/icon_button';
// Our imports //
import ComposeAdvancedOptionsToggle from './toggle';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. These are the various titles and labels on our
toggles.
`iconStyle` styles the icon used for the dropdown button.
*/
const messages = defineMessages({
local_only_short :
{ id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
local_only_long :
{ id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
advanced_options_icon_title :
{ id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
});
const iconStyle = {
height : null,
lineHeight : '27px',
};
/*
Implementation:
---------------
*/
@injectIntl
export default class ComposeAdvancedOptions extends React.PureComponent {
static propTypes = {
values : ImmutablePropTypes.contains({
do_not_federate : PropTypes.bool.isRequired,
}).isRequired,
onChange : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
state = {
open: false,
};
/*
### `onToggleDropdown()`
This function toggles the opening and closing of the advanced options
dropdown.
*/
onToggleDropdown = () => {
this.setState({ open: !this.state.open });
};
/*
### `onGlobalClick(e)`
This function closes the advanced options dropdown if you click
anywhere else on the screen.
*/
onGlobalClick = (e) => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
}
/*
### `componentDidMount()`, `componentWillUnmount()`
This function closes the advanced options dropdown if you click
anywhere else on the screen.
*/
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
/*
### `setRef(c)`
`setRef()` stores a reference to the dropdown's `<div> in `this.node`.
*/
setRef = (c) => {
this.node = c;
}
/*
### `render()`
`render()` actually puts our component on the screen.
*/
render () {
const { open } = this.state;
const { intl, values } = this.props;
/*
The `options` array provides all of the available advanced options
alongside their icon, text, and name.
*/
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
];
/*
`anyEnabled` tells us if any of our advanced options have been enabled.
*/
const anyEnabled = values.some((enabled) => enabled);
/*
`optionElems` takes our `options` and creates
`<ComposeAdvancedOptionsToggle>`s out of them. We use the `name` of the
toggle as its `key` so that React can keep track of it.
*/
const optionElems = options.map((option) => {
return (
<ComposeAdvancedOptionsToggle
onChange={this.props.onChange}
active={values.get(option.name)}
key={option.name}
name={option.name}
shortText={intl.formatMessage(option.shortText)}
longText={intl.formatMessage(option.longText)}
/>
);
});
/*
Finally, we can render our component.
*/
return (
<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}>
<div className='advanced-options-dropdown__value'>
<IconButton
className='advanced-options-dropdown__value'
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='ellipsis-h' active={open || anyEnabled}
size={18}
style={iconStyle}
onClick={this.onToggleDropdown}
/>
</div>
<div className='advanced-options-dropdown__dropdown'>
{optionElems}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,103 @@
/*
`<ComposeAdvancedOptionsToggle>`
================================
> For more information on the contents of this file, please contact:
>
> - surinna [@srn@dev.glitch.social]
This creates the toggle used by `<ComposeAdvancedOptions>`.
__Props:__
- __`onChange` (`PropTypes.func`) :__
This provides the function to call when the toggle is
(de-?)activated.
- __`active` (`PropTypes.bool`) :__
This prop controls whether the toggle is currently active or not.
- __`name` (`PropTypes.string`) :__
This identifies the toggle, and is sent to `onChange()` when it is
called.
- __`shortText` (`PropTypes.string`) :__
This is a short string used as the title of the toggle.
- __`longText` (`PropTypes.string`) :__
This is a longer string used as a subtitle for the toggle.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import Toggle from 'react-toggle';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
shortText: PropTypes.string.isRequired,
longText: PropTypes.string.isRequired,
}
/*
### `onToggle()`
The `onToggle()` function simply calls the `onChange()` prop with the
toggle's `name`.
*/
onToggle = () => {
this.props.onChange(this.props.name);
}
/*
### `render()`
The `render()` function is used to render our component. We just render
a `<Toggle>` and place next to it our text.
*/
render() {
const { active, shortText, longText } = this.props;
return (
<div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
<div className='advanced-options-dropdown__option__toggle'>
<Toggle checked={active} onChange={this.onToggle} />
</div>
<div className='advanced-options-dropdown__option__content'>
<strong>{shortText}</strong>
{longText}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,52 @@
// <ListConversationContainer>
// =================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation/container
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import { connect } from 'react-redux';
// Mastodon imports.
import { fetchContext } from 'mastodon/actions/statuses';
// Our imports.
import ListConversation from '.';
// * * * * * * * //
// State mapping
// -------------
const mapStateToProps = (state, { id }) => {
return {
ancestors : state.getIn(['contexts', 'ancestors', id]),
descendants : state.getIn(['contexts', 'descendants', id]),
};
};
// * * * * * * * //
// Dispatch mapping
// ----------------
const mapDispatchToProps = (dispatch) => ({
fetch (id) {
dispatch(fetchContext(id));
},
});
// * * * * * * * //
// Connecting
// ----------
export default connect(mapStateToProps, mapDispatchToProps)(ListConversation);

View File

@@ -0,0 +1,80 @@
// <ListConversation>
// ====================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ScrollContainer from 'react-router-scroll';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import StatusContainer from 'glitch/components/status/container';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class ListConversation extends ImmutablePureComponent {
// Props.
static propTypes = {
id: PropTypes.number.isRequired,
ancestors: ImmutablePropTypes.list,
descendants: ImmutablePropTypes.list,
fetch: PropTypes.func.isRequired,
}
// If this is a detailed status, we should fetch its contents and
// context upon mounting.
componentWillMount () {
const { id, fetch } = this.props;
fetch(id);
}
// Similarly, if the component receives new props, we need to fetch
// the new status.
componentWillReceiveProps (nextProps) {
const { id, fetch } = this.props;
if (nextProps.id !== id) fetch(nextProps.id);
}
// We just render our status inside a column with its
// ancestors and decendants.
render () {
const { id, ancestors, descendants } = this.props;
return (
<ScrollContainer scrollKey='thread'>
<div className='glitch glitch__list__conversation scrollable'>
{ancestors && ancestors.size > 0 ? (
ancestors.map(
ancestor => <StatusContainer key={ancestor} id={ancestor} route />
)
) : null}
<StatusContainer key={id} id={id} detailed route />
{descendants && descendants.size > 0 ? (
descendants.map(
descendant => <StatusContainer key={descendant} id={descendant} route />
)
) : null}
</div>
</ScrollContainer>
);
}
};

View File

@@ -0,0 +1,80 @@
/*
`<NotificationPurgeButtonsContainer>`
=========================
This container connects `<NotificationPurgeButtons>`s to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Our imports //
import NotificationPurgeButtons from './notification_purge_buttons';
import {
deleteMarkedNotifications,
enterNotificationClearingMode,
markAllNotifications,
} from '../../../../mastodon/actions/notifications';
import { defineMessages, injectIntl } from 'react-intl';
import { openModal } from '../../../../mastodon/actions/modal';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We only need to provide a dispatch for
deleting notifications.
*/
const messages = defineMessages({
clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onEnterCleaningMode(yes) {
dispatch(enterNotificationClearingMode(yes));
},
onDeleteMarked() {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(deleteMarkedNotifications()),
}));
},
onMarkAll() {
dispatch(markAllNotifications(true));
},
onMarkNone() {
dispatch(markAllNotifications(false));
},
onInvert() {
dispatch(markAllNotifications(null));
},
});
const mapStateToProps = state => ({
markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));

View File

@@ -0,0 +1,62 @@
/**
* Buttons widget for controlling the notification clearing mode.
* In idle state, the cleaning mode button is shown. When the mode is active,
* a Confirm and Abort buttons are shown in its place.
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
});
@injectIntl
export default class NotificationPurgeButtons extends ImmutablePureComponent {
static propTypes = {
onDeleteMarked : PropTypes.func.isRequired,
onMarkAll : PropTypes.func.isRequired,
onMarkNone : PropTypes.func.isRequired,
onInvert : PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
markNewForDelete: PropTypes.bool,
};
render () {
const { intl, markNewForDelete } = this.props;
//className='active'
return (
<div className='column-header__notif-cleaning-buttons'>
<button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}>
<b></b><br />{intl.formatMessage(messages.btnAll)}
</button>
<button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}>
<b></b><br />{intl.formatMessage(messages.btnNone)}
</button>
<button onClick={this.props.onInvert}>
<b>¬</b><br />{intl.formatMessage(messages.btnInvert)}
</button>
<button onClick={this.props.onDeleteMarked}>
<i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)}
</button>
</div>
);
}
}

View File

@@ -0,0 +1,209 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types';
import IntersectionObserverWrapper from 'mastodon/features/ui/util/intersection_observer_wrapper';
import { throttle } from 'lodash';
import { defineMessages, injectIntl } from 'react-intl';
import StatusContainer from 'glitch/components/status/container';
import CommonButton from 'glitch/components/common/button';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
@injectIntl
export default class ListStatuses extends ImmutablePureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
onScrollToBottom: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
};
static defaultProps = {
trackScroll: true,
};
state = {
currentDetail: null,
};
intersectionObserverWrapper = new IntersectionObserverWrapper();
handleScroll = throttle(() => {
if (this.node) {
const { scrollTop, scrollHeight, clientHeight } = this.node;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
this.props.onScrollToBottom();
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
} else if (this.props.onScroll) {
this.props.onScroll();
}
}
}, 150, {
trailing: true,
});
componentDidMount () {
this.attachScrollListener();
this.attachIntersectionObserver();
// Handle initial scroll posiiton
this.handleScroll();
}
componentDidUpdate (prevProps) {
// Reset the scroll position when a new toot comes in in order not to
// jerk the scrollbar around if you're already scrolled down the page.
if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
if (this.node.scrollTop !== newScrollTop) {
this.node.scrollTop = newScrollTop;
}
} else {
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
}
}
}
componentWillUnmount () {
this.detachScrollListener();
this.detachIntersectionObserver();
}
attachIntersectionObserver () {
this.intersectionObserverWrapper.connect({
root: this.node,
rootMargin: '300% 0px',
});
}
detachIntersectionObserver () {
this.intersectionObserverWrapper.disconnect();
}
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
}
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
}
setRef = (c) => {
this.node = c;
}
handleLoadMore = (e) => {
e.preventDefault();
this.props.onScrollToBottom();
}
handleKeyDown = (e) => {
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
const article = (() => {
switch (e.key) {
case 'PageDown':
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
case 'PageUp':
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
case 'End':
return this.node.querySelector('[role="feed"] > article:last-of-type');
case 'Home':
return this.node.querySelector('[role="feed"] > article:first-of-type');
default:
return null;
}
})();
if (article) {
e.preventDefault();
article.focus();
article.scrollIntoView();
}
}
}
handleSetDetail = (id) => {
this.setState({ currentDetail : id });
}
render () {
const {
handleKeyDown,
handleLoadMore,
handleSetDetail,
intersectionObserverWrapper,
setRef,
} = this;
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, intl } = this.props;
const { currentDetail } = this.state;
const loadMore = (
<CommonButton
className='load-more'
disabled={isLoading || statusIds.size > 0 && hasMore}
onClick={handleLoadMore}
showTitle
title={intl.formatMessage(messages.load_more)}
/>
);
let scrollableArea = null;
if (isLoading || statusIds.size > 0 || !emptyMessage) {
scrollableArea = (
<div className='scrollable' ref={setRef}>
<div role='feed' className='status-list' onKeyDown={handleKeyDown}>
{prepend}
{statusIds.map((statusId, index) => (
<StatusContainer
key={statusId}
id={statusId}
index={index}
listLength={statusIds.size}
detailed={currentDetail === statusId}
setDetail={handleSetDetail}
intersectionObserverWrapper={intersectionObserverWrapper}
/>
))}
{loadMore}
</div>
</div>
);
} else {
scrollableArea = (
<div className='empty-column-indicator' ref={setRef}>
{emptyMessage}
</div>
);
}
if (trackScroll) {
return (
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea}
</ScrollContainer>
);
} else {
return scrollableArea;
}
}
}

View File

@@ -0,0 +1,24 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { closeModal } from 'mastodon/actions/modal';
// Our imports //
import { changeLocalSetting } from 'glitch/actions/local_settings';
import LocalSettings from '.';
const mapStateToProps = state => ({
settings: state.get('local_settings'),
});
const mapDispatchToProps = dispatch => ({
onChange (setting, value) {
dispatch(changeLocalSetting(setting, value));
},
onClose () {
dispatch(closeModal());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings);

View File

@@ -0,0 +1,50 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Our imports
import LocalSettingsPage from './page';
import LocalSettingsNavigation from './navigation';
// Stylesheet imports
import './style';
export default class LocalSettings extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
state = {
currentIndex: 0,
};
navigateTo = (index) =>
this.setState({ currentIndex: +index });
render () {
const { navigateTo } = this;
const { onChange, onClose, settings } = this.props;
const { currentIndex } = this.state;
return (
<div className='glitch modal-root__modal local-settings'>
<LocalSettingsNavigation
index={currentIndex}
onClose={onClose}
onNavigate={navigateTo}
/>
<LocalSettingsPage
index={currentIndex}
onChange={onChange}
settings={settings}
/>
</div>
);
}
}

View File

@@ -0,0 +1,74 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports
import LocalSettingsNavigationItem from './item';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
general: { id: 'settings.general', defaultMessage: 'General' },
collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
media: { id: 'settings.media', defaultMessage: 'Media' },
preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
close: { id: 'settings.close', defaultMessage: 'Close' },
});
@injectIntl
export default class LocalSettingsNavigation extends React.PureComponent {
static propTypes = {
index : PropTypes.number,
intl : PropTypes.object.isRequired,
onClose : PropTypes.func.isRequired,
onNavigate : PropTypes.func.isRequired,
};
render () {
const { index, intl, onClose, onNavigate } = this.props;
return (
<nav className='glitch local-settings__navigation'>
<LocalSettingsNavigationItem
active={index === 0}
index={0}
onNavigate={onNavigate}
title={intl.formatMessage(messages.general)}
/>
<LocalSettingsNavigationItem
active={index === 1}
index={1}
onNavigate={onNavigate}
title={intl.formatMessage(messages.collapsed)}
/>
<LocalSettingsNavigationItem
active={index === 2}
index={2}
onNavigate={onNavigate}
title={intl.formatMessage(messages.media)}
/>
<LocalSettingsNavigationItem
active={index === 3}
href='/settings/preferences'
index={3}
icon='cog'
title={intl.formatMessage(messages.preferences)}
/>
<LocalSettingsNavigationItem
active={index === 4}
className='close'
index={4}
onNavigate={onClose}
title={intl.formatMessage(messages.close)}
/>
</nav>
);
}
}

View File

@@ -0,0 +1,69 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default class LocalSettingsPage extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
className: PropTypes.string,
href: PropTypes.string,
icon: PropTypes.string,
index: PropTypes.number.isRequired,
onNavigate: PropTypes.func,
title: PropTypes.string,
};
handleClick = (e) => {
const { index, onNavigate } = this.props;
if (onNavigate) {
onNavigate(index);
e.preventDefault();
}
}
render () {
const { handleClick } = this;
const {
active,
className,
href,
icon,
onNavigate,
title,
} = this.props;
const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
active,
}, className);
const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : null;
if (href) return (
<a
href={href}
className={finalClassName}
>
{iconElem} {title}
</a>
);
else if (onNavigate) return (
<a
onClick={handleClick}
role='button'
tabIndex='0'
className={finalClassName}
>
{iconElem} {title}
</a>
);
else return null;
}
}

View File

@@ -0,0 +1,27 @@
@import 'variables';
.glitch.local-settings__navigation__item {
display: block;
padding: 15px 20px;
color: inherit;
background: $primary-text-color;
border-bottom: 1px $ui-primary-color solid;
cursor: pointer;
text-decoration: none;
outline: none;
transition: background .3s;
&:hover {
background: $ui-secondary-color;
}
&.active {
background: $ui-highlight-color;
color: $primary-text-color;
}
&.close, &.close:hover {
background: $error-value-color;
color: $primary-text-color;
}
}

View File

@@ -0,0 +1,10 @@
@import 'variables';
.glitch.local-settings__navigation {
background: $primary-text-color;
color: $ui-base-color;
width: 200px;
font-size: 15px;
line-height: 20px;
overflow-y: auto;
}

View File

@@ -0,0 +1,183 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
// Our imports
import LocalSettingsPageItem from './item';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
});
@injectIntl
export default class LocalSettingsPage extends React.PureComponent {
static propTypes = {
index : PropTypes.number,
intl : PropTypes.object.isRequired,
onChange : PropTypes.func.isRequired,
settings : ImmutablePropTypes.map.isRequired,
};
pages = [
({ intl, onChange, settings }) => (
<div className='glitch local-settings__page general'>
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
]}
onChange={onChange}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['stretch']}
id='mastodon-settings--stretch'
onChange={onChange}
>
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['navbar_under']}
id='mastodon-settings--navbar_under'
onChange={onChange}
>
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
</LocalSettingsPageItem>
</div>
),
({ onChange, settings }) => (
<div className='glitch local-settings__page collapsed'>
<h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'enabled']}
id='mastodon-settings--collapsed-enabled'
onChange={onChange}
>
<FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
</LocalSettingsPageItem>
<section>
<h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'all']}
id='mastodon-settings--collapsed-auto-all'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'notifications']}
id='mastodon-settings--collapsed-auto-notifications'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'lengthy']}
id='mastodon-settings--collapsed-auto-lengthy'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'replies']}
id='mastodon-settings--collapsed-auto-replies'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'media']}
id='mastodon-settings--collapsed-auto-media'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
</LocalSettingsPageItem>
</section>
<section>
<h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'backgrounds', 'user_backgrounds']}
id='mastodon-settings--collapsed-user-backgrouns'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'backgrounds', 'preview_images']}
id='mastodon-settings--collapsed-preview-images'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
</LocalSettingsPageItem>
</section>
</div>
),
({ onChange, settings }) => (
<div className='glitch local-settings__page media'>
<h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['media', 'letterbox']}
id='mastodon-settings--media-letterbox'
onChange={onChange}
>
<FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['media', 'fullwidth']}
id='mastodon-settings--media-fullwidth'
onChange={onChange}
>
<FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
</LocalSettingsPageItem>
</div>
),
];
render () {
const { pages } = this;
const { index, intl, onChange, settings } = this.props;
const CurrentPage = pages[index] || pages[0];
return <CurrentPage intl={intl} onChange={onChange} settings={settings} />;
}
}

View File

@@ -0,0 +1,90 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default class LocalSettingsPageItem extends React.PureComponent {
static propTypes = {
children: PropTypes.element.isRequired,
dependsOn: PropTypes.array,
dependsOnNot: PropTypes.array,
id: PropTypes.string.isRequired,
item: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
})),
settings: ImmutablePropTypes.map.isRequired,
};
handleChange = e => {
const { target } = e;
const { item, onChange, options } = this.props;
if (options && options.length > 0) onChange(item, target.value);
else onChange(item, target.checked);
}
render () {
const { handleChange } = this;
const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
let enabled = true;
if (dependsOn) {
for (let i = 0; i < dependsOn.length; i++) {
enabled = enabled && settings.getIn(dependsOn[i]);
}
}
if (dependsOnNot) {
for (let i = 0; i < dependsOnNot.length; i++) {
enabled = enabled && !settings.getIn(dependsOnNot[i]);
}
}
if (options && options.length > 0) {
const currentValue = settings.getIn(item);
const optionElems = options && options.length > 0 && options.map((opt) => (
<option
key={opt.value}
value={opt.value}
>
{opt.message}
</option>
));
return (
<label className='glitch local-settings__page__item' htmlFor={id}>
<p>{children}</p>
<p>
<select
id={id}
disabled={!enabled}
onBlur={handleChange}
onChange={handleChange}
value={currentValue}
>
{optionElems}
</select>
</p>
</label>
);
} else return (
<label className='glitch local-settings__page__item' htmlFor={id}>
<input
id={id}
type='checkbox'
checked={settings.getIn(item)}
onChange={handleChange}
disabled={!enabled}
/>
{children}
</label>
);
}
}

View File

@@ -0,0 +1,7 @@
@import 'variables';
.glitch.local-settings__page__item {
select {
margin-bottom: 5px;
}
}

View File

@@ -0,0 +1,9 @@
@import 'variables';
.glitch.local-settings__page {
display: block;
flex: auto;
padding: 15px 20px 15px 20px;
width: 360px;
overflow-y: auto;
}

View File

@@ -0,0 +1,34 @@
@import 'variables';
.glitch.local-settings {
position: relative;
display: flex;
flex-direction: row;
background: $ui-secondary-color;
color: $ui-base-color;
border-radius: 8px;
height: 80vh;
width: 80vw;
max-width: 740px;
max-height: 450px;
overflow: hidden;
label {
display: block;
}
h1 {
font-size: 18px;
font-weight: 500;
line-height: 24px;
margin-bottom: 20px;
}
h2 {
font-size: 15px;
font-weight: 500;
line-height: 20px;
margin-top: 20px;
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,56 @@
/*
`<NotificationContainer>`
=========================
This container connects `<Notification>`s to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { makeGetNotification } from '../../../mastodon/selectors';
// Our imports //
import Notification from '.';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in `makeMapStateToProps()` so that
we only have to call `makeGetNotification()` once instead of every
time.
*/
const makeMapStateToProps = () => {
const getNotification = makeGetNotification();
const mapStateToProps = (state, props) => ({
notification: getNotification(state, props.notification, props.accountId),
settings: state.get('local_settings'),
notifCleaning: state.getIn(['notifications', 'cleaningMode']),
});
return mapStateToProps;
};
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default connect(makeMapStateToProps)(Notification);

View File

@@ -0,0 +1,124 @@
/*
`<NotificationFollow>`
======================
This component renders a follow notification.
__Props:__
- __`id` (`PropTypes.number.isRequired`) :__
This is the id of the notification.
- __`onDeleteNotification` (`PropTypes.func.isRequired`) :__
The function to call when a notification should be
dismissed/deleted.
- __`account` (`PropTypes.object.isRequired`) :__
The account associated with the follow notification, ie the account
which followed the user.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import Permalink from '../../../mastodon/components/permalink';
import AccountContainer from '../../../mastodon/containers/account_container';
// Our imports //
import NotificationOverlayContainer from '../notification/overlay/container';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
export default class NotificationFollow extends ImmutablePureComponent {
static propTypes = {
id : PropTypes.number.isRequired,
account : ImmutablePropTypes.map.isRequired,
notification : ImmutablePropTypes.map.isRequired,
};
/*
### `render()`
This actually renders the component.
*/
render () {
const { account, notification } = this.props;
/*
`link` is a container for the account's `displayName`, which links to
the account timeline using a `<Permalink>`.
*/
const displayName = account.get('display_name') || account.get('username');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = (
<Permalink
className='notification__display-name'
href={account.get('url')}
title={account.get('acct')}
to={`/accounts/${account.get('id')}`}
dangerouslySetInnerHTML={displayNameHTML}
/>
);
/*
We can now render our component.
*/
return (
<div className='notification notification-follow'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={{ name: link }}
/>
</div>
<AccountContainer id={account.get('id')} withNote={false} />
<NotificationOverlayContainer notification={notification} />
</div>
);
}
}

View File

@@ -0,0 +1,90 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
// Our imports //
import StatusContainer from '../status/container';
import NotificationFollow from './follow';
import NotificationOverlayContainer from './overlay/container';
export default class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
renderFollow (notification) {
return (
<NotificationFollow
id={notification.get('id')}
account={notification.get('account')}
notification={notification}
/>
);
}
renderMention (notification) {
return (
<StatusContainer
id={notification.get('status')}
notification={notification}
withDismiss
/>
);
}
renderFavourite (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='favourite'
muted
notification={notification}
withDismiss
/>
);
}
renderReblog (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='reblog'
muted
notification={notification}
withDismiss
/>
);
}
render () {
const { notification } = this.props;
return (
<div class='status'>
{(() => {
switch (notification.get('type')) {
case 'follow':
return this.renderFollow(notification);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification);
case 'reblog':
return this.renderReblog(notification);
default:
return null;
}
})()}
<NotificationOverlayContainer notification={notification} />
</div>
);
}
}

View File

@@ -0,0 +1,39 @@
// <NotificationOverlayContainer>
// ==============================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/notification/overlay/container
// * * * * * * * //
// Imports
// -------
// Package imports.
import { connect } from 'react-redux';
// Mastodon imports.
import { markNotificationForDelete } from 'mastodon/actions/notifications';
// Our imports.
import NotificationOverlay from './notification_overlay';
// State mapping
// -------------
const mapStateToProps = state => ({
show: state.getIn(['notifications', 'cleaningMode']),
});
// Dispatch mapping
// ----------------
const mapDispatchToProps = dispatch => ({
onMarkForDelete(id, yes) {
dispatch(markNotificationForDelete(id, yes));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);

View File

@@ -0,0 +1,57 @@
/**
* Notification overlay
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
});
@injectIntl
export default class NotificationOverlay extends ImmutablePureComponent {
static propTypes = {
notification : ImmutablePropTypes.map.isRequired,
onMarkForDelete : PropTypes.func.isRequired,
show : PropTypes.bool.isRequired,
intl : PropTypes.object.isRequired,
};
onToggleMark = () => {
const mark = !this.props.notification.get('markedForDelete');
const id = this.props.notification.get('id');
this.props.onMarkForDelete(id, mark);
}
render () {
const { notification, show, intl } = this.props;
const active = notification.get('markedForDelete');
const label = intl.formatMessage(messages.markForDeletion);
return show ? (
<div
aria-label={label}
role='checkbox'
aria-checked={active}
tabIndex={0}
className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
onClick={this.onToggleMark}
>
<div className='wrappy'>
<div className='ckbox' aria-hidden='true' title={label}>
{active ? (<i className='fa fa-check' />) : ''}
</div>
</div>
</div>
) : null;
}
}

View File

@@ -0,0 +1,268 @@
// <StatusActionBar>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/action_bar
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
delete:
{ id: 'status.delete', defaultMessage: 'Delete' },
mention:
{ id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute:
{ id: 'account.mute', defaultMessage: 'Mute @{name}' },
block:
{ id: 'account.block', defaultMessage: 'Block @{name}' },
reply:
{ id: 'status.reply', defaultMessage: 'Reply' },
replyAll:
{ id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog:
{ id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog:
{ id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite:
{ id: 'status.favourite', defaultMessage: 'Favourite' },
open:
{ id: 'status.open', defaultMessage: 'Expand this status' },
report:
{ id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation:
{ id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation:
{ id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
share:
{ id: 'status.share', defaultMessage: 'Share' },
more:
{ id: 'status.more', defaultMessage: 'More' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusActionBar extends ImmutablePureComponent {
// Props.
static propTypes = {
detailed: PropTypes.bool,
handler: PropTypes.objectOf(PropTypes.func).isRequired,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
me: PropTypes.number,
status: ImmutablePropTypes.map.isRequired,
};
// These handle all of our actions.
handleReplyClick = () => {
const { handler, history, status } = this.props;
handler.reply(status, { history }); // hack
}
handleFavouriteClick = () => {
const { handler, status } = this.props;
handler.favourite(status);
}
handleReblogClick = (e) => {
const { handler, status } = this.props;
handler.reblog(status, e.shiftKey);
}
handleDeleteClick = () => {
const { handler, status } = this.props;
handler.delete(status);
}
handleMentionClick = () => {
const { handler, history, status } = this.props;
handler.mention(status.get('account'), { history }); // hack
}
handleMuteClick = () => {
const { handler, status } = this.props;
handler.mute(status.get('account'));
}
handleBlockClick = () => {
const { handler, status } = this.props;
handler.block(status.get('account'));
}
handleOpen = () => {
const { history, status } = this.props;
history.push(`/statuses/${status.get('id')}`);
}
handleReport = () => {
const { handler, status } = this.props;
handler.report(status);
}
handleShare = () => {
const { status } = this.props;
navigator.share({
text: status.get('search_index'),
url: status.get('url'),
});
}
handleConversationMuteClick = () => {
const { handler, status } = this.props;
handler.muteConversation(status);
}
// Renders our component.
render () {
const {
handleBlockClick,
handleConversationMuteClick,
handleDeleteClick,
handleFavouriteClick,
handleMentionClick,
handleMuteClick,
handleOpen,
handleReblogClick,
handleReplyClick,
handleReport,
handleShare,
} = this;
const { detailed, intl, me, status } = this.props;
const account = status.get('account');
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
const reblogTitle = reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog);
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
let menu = [];
let replyIcon;
let replyTitle;
// This builds our menu.
if (!detailed) {
menu.push({
text: intl.formatMessage(messages.open),
action: handleOpen,
});
menu.push(null);
}
menu.push({
text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation),
action: handleConversationMuteClick,
});
menu.push(null);
if (account.get('id') === me) {
menu.push({
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
});
} else {
menu.push({
text: intl.formatMessage(messages.mention, {
name: account.get('username'),
}),
action: handleMentionClick,
});
menu.push(null);
menu.push({
text: intl.formatMessage(messages.mute, {
name: account.get('username'),
}),
action: handleMuteClick,
});
menu.push({
text: intl.formatMessage(messages.block, {
name: account.get('username'),
}),
action: handleBlockClick,
});
menu.push({
text: intl.formatMessage(messages.report, {
name: account.get('username'),
}),
action: handleReport,
});
}
// This selects our reply icon.
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
// Now we can render the component.
return (
<div className='glitch glitch__status__action-bar'>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess}
title={replyTitle}
icon={replyIcon}
onClick={handleReplyClick}
/>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess || reblogDisabled}
active={status.get('reblogged')}
title={reblogTitle}
icon='retweet'
onClick={handleReblogClick}
/>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess}
animate
active={status.get('favourited')}
title={intl.formatMessage(messages.favourite)}
icon='star'
onClick={handleFavouriteClick}
/>
{
'share' in navigator ? (
<CommonButton
className='action-bar\button'
disabled={status.get('visibility') !== 'public'}
title={intl.formatMessage(messages.share)}
icon='share-alt'
onClick={handleShare}
/>
) : null
}
<div className='action-bar\button'>
<DropdownMenuContainer
items={menu}
disabled={anonymousAccess}
icon='ellipsis-h'
size={18}
direction='right'
aria-label={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,28 @@
@import 'variables';
.glitch.glitch__status__action-bar {
display: block;
height: 1.25em;
font-size: 1.25em;
line-height: 1;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
// Dropdown style override for centering on the icon
.dropdown--active {
position: relative;
.dropdown__content.dropdown__right {
left: calc(50% + 3px);
right: initial;
transform: translate(-50%, 0);
top: 22px;
}
&::after {
right: 1px;
bottom: -2px;
}
}
}

View File

@@ -0,0 +1,212 @@
// <StatusContainer>
// =================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/container
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { createStructuredSelector } from 'reselect';
// Mastodon imports.
import { blockAccount, muteAccount } from 'mastodon/actions/accounts';
import {
replyCompose,
mentionCompose,
} from 'mastodon/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
} from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { initReport } from 'mastodon/actions/reports';
import {
muteStatus,
unmuteStatus,
deleteStatus,
} from 'mastodon/actions/statuses';
import { fetchStatusCard } from 'mastodon/actions/cards';
// Our imports.
import Status from '.';
import makeStatusSelector from 'glitch/selectors/status';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
blockConfirm : {
id : 'confirmations.block.confirm',
defaultMessage : 'Block',
},
deleteConfirm : {
id : 'confirmations.delete.confirm',
defaultMessage : 'Delete',
},
deleteMessage : {
id : 'confirmations.delete.message',
defaultMessage : 'Are you sure you want to delete this status?',
},
muteConfirm : {
id : 'confirmations.mute.confirm',
defaultMessage : 'Mute',
},
});
// * * * * * * * //
// State mapping
// -------------
// We wrap our `mapStateToProps()` function in a
// `makeMapStateToProps()` to give us a closure and preserve
// `makeGetStatus()`'s value.
const makeMapStateToProps = () => {
const statusSelector = makeStatusSelector();
// State mapping.
return (state, ownProps) => {
let status = statusSelector(state, ownProps.id);
let reblogStatus = status.get('reblog', null);
let comrade = undefined;
let prepend = undefined;
// Processes reblogs and generates their prepend.
if (reblogStatus !== null && typeof reblogStatus === 'object') {
comrade = status.get('account');
status = reblogStatus;
prepend = 'reblogged';
}
// This is what we pass to <Status>.
return {
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
comrade: comrade || ownProps.comrade,
deleteModal: state.getIn(['meta', 'delete_modal']),
me: state.getIn(['meta', 'me']),
prepend: prepend || ownProps.prepend,
reblogModal: state.getIn(['meta', 'boost_modal']),
settings: state.get('local_settings'),
status: status,
};
};
};
// * * * * * * * //
// Dispatch mapping
// ----------------
const makeMapDispatchToProps = (dispatch) => {
const dispatchSelector = createStructuredSelector({
handler: ({ intl }) => ({
block (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
}));
},
delete (status) {
if (!this.deleteModal) { // TODO: THIS IS BORKN (this refers to handler)
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
}));
}
},
favourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
fetchCard (status) {
dispatch(fetchStatusCard(status.get('id')));
},
mention (account, router) {
dispatch(mentionCompose(account, router));
},
modalReblog (status) {
dispatch(reblog(status));
},
mute (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id'))),
}));
},
muteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
openMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
},
openVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
reblog (status, withShift) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (withShift || !this.reblogModal) { // TODO: THIS IS BORKN (this refers to handler)
this.modalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.modalReblog }));
}
}
},
reply (status, router) {
dispatch(replyCompose(status, router));
},
report (status) {
dispatch(initReport(status.get('account'), status));
},
}),
});
return (_, ownProps) => dispatchSelector(ownProps);
};
// * * * * * * * //
// Connecting
// ----------
// `connect` will only update when its resultant props change. So
// `withRouter` won't get called unless an update is already planned.
// This is intended behaviour because we only care about the (mutable)
// `history` object.
export default injectIntl(
connect(makeMapStateToProps, makeMapDispatchToProps)(
withRouter(Status)
)
);

View File

@@ -0,0 +1,190 @@
// <StatusContentCard>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/card
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import punycode from 'punycode';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
import emojify from 'mastodon/emoji';
// Our imports.
import CommonLink from 'glitch/components/common/link';
import CommonSeparator from 'glitch/components/common/separator';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Reliably gets the hostname from a URL.
const getHostname = url => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
// * * * * * * * //
// The component
// -------------
export default class Card extends ImmutablePureComponent {
// Props.
static propTypes = {
card: ImmutablePropTypes.map.isRequired,
fullwidth: PropTypes.bool,
letterbox: PropTypes.bool,
}
// Rendering.
render () {
const { card, fullwidth, letterbox } = this.props;
let media = null;
let text = null;
let author = null;
let provider = null;
let caption = null;
// This gets all of our card properties.
const authorName = card.get('author_name');
const authorUrl = card.get('author_url');
const description = card.get('description');
const html = card.get('html');
const image = card.get('image');
const providerName = card.get('provider_name');
const providerUrl = card.get('provider_url');
const title = card.get('title');
const type = card.get('type');
const url = card.get('url');
// Sets our class.
const computedClass = classNames('glitch', 'glitch__status__content__card', type, {
_fullwidth: fullwidth,
_letterbox: letterbox,
});
// A card is required to render.
if (!card) return null;
// This generates our card media (image or video).
switch(type) {
case 'photo':
media = (
<CommonLink
className='card\media card\photo'
href={url}
>
<img
alt={title}
src={image}
/>
</CommonLink>
);
break;
case 'video':
media = (
<div
className='card\media card\video'
dangerouslySetInnerHTML={{ __html: html }}
/>
);
break;
}
// If we have at least a title or a description, then we can
// render some textual contents.
if (title || description) {
text = (
<CommonLink
className='card\description'
href={url}
>
{type === 'link' && image ? (
<div className='card\thumbnail'>
<img
alt=''
className='card\image'
src={image}
/>
</div>
) : null}
{title ? (
<h1 className='card\title'>{title}</h1>
) : null}
{emojify(description)}
</CommonLink>
);
}
// This creates links or spans (depending on whether a URL was
// provided) for the card author and provider.
if (authorUrl) {
author = (
<CommonLink
className='card\author card\link'
href={authorUrl}
>
{authorName ? authorName : punycode.toUnicode(getHostname(authorUrl))}
</CommonLink>
);
} else if (authorName) {
author = <span className='card\author'>{authorName}</span>;
}
if (providerUrl) {
provider = (
<CommonLink
className='card\provider card\link'
href={providerUrl}
>
{providerName ? providerName : punycode.toUnicode(getHostname(providerUrl))}
</CommonLink>
);
} else if (providerName) {
provider = <span className='card\provider'>{providerName}</span>;
}
// If we have either the author or the provider, then we can
// render an attachment.
if (author || provider) {
caption = (
<figcaption className='card\caption'>
{author}
<CommonSeparator
className='card\separator'
visible={author && provider}
/>
{provider}
</figcaption>
);
}
// Putting the pieces together and returning.
return (
<figure className={computedClass}>
{media}
{text}
{caption}
</figure>
);
}
}

View File

@@ -0,0 +1,123 @@
@import 'variables';
.glitch.glitch__content__card {
display: block;
border: thin $glitch-texture-color solid;
border-radius: .35em;
background: $glitch-darker-color;
.card\\caption {
color: $ui-primary-color;
background: $glitch-texture-color;
font-size: (1.25em / 1.35); // approx. .925em
.card\\link { // caption links
color: inherit;
&:hover {
text-decoration: underline;
}
}
}
.card\\media {
display: block;
position: relative;
width: 100%;
height: 13.5em;
/*
Our fallback styles letterbox the media, but we'll expand it to
fill the container if supported. This won't do anything for
`<iframe>`s, but we'll just have to trust them to manage their
own content.
*/
& > * {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.card\\description {
color: $ui-secondary-color;
background: $ui-base-color;
.card\\thumbnail {
position: relative;
float: left;
width: 6.75em;
height: 100%;
background: $glitch-darker-color;
& > img {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
/*
We have to divide the bottom margin of titles by their font-size to
get them to match what we use elsewhere.
*/
.card\\title {
margin-bottom: (.75em * 1.35 / 1.5);
font-size: 1.5em;
line-height: 1.125; // = 1.35 * (1.25 / 1.5)
}
}
&._fullwidth {
margin-left: -.75em;
width: calc(100% + 1.5em);
}
/*
If `letterbox` is specified, then we don't need object-fit (since
we essentially just do a scale-down).
*/
&._letterbox {
.card\\description .card\\thumbnail {
& > img {
width: auto;
height: auto;
object-fit: fill;
}
}
.card\\media {
& > * {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -0,0 +1,191 @@
// <StatusContentGallery>
// ======================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Our imports.
import StatusContentGalleryItem from './item';
import StatusContentGalleryPlayer from './player';
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
hide: { id: 'media_gallery.hide_media', defaultMessage: 'Hide media' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGallery extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
autoPlayGif: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
sensitive: PropTypes.bool,
standalone: PropTypes.bool,
};
state = {
visible: !this.props.sensitive,
};
// Handles media clicks.
handleMediaClick = index => {
const { attachments, onOpenMedia, standalone } = this.props;
if (standalone) return;
onOpenMedia(attachments, index);
}
// Handles showing and hiding.
handleToggle = () => {
this.setState({ visible: !this.state.visible });
}
// Handles video clicks.
handleVideoClick = time => {
const { attachments, onOpenVideo, standalone } = this.props;
if (standalone) return;
onOpenVideo(attachments.get(0), time);
}
// Renders.
render () {
const { handleMediaClick, handleToggle, handleVideoClick } = this;
const {
attachments,
autoPlayGif,
fullwidth,
intl,
letterbox,
sensitive,
} = this.props;
const { visible } = this.state;
const computedClass = classNames('glitch', 'glitch__status__content__gallery', {
_fullwidth: fullwidth,
});
const useableAttachments = attachments.take(4);
let button;
let children;
let size;
// This handles hidden media
if (!this.state.visible) {
button = (
<CommonButton
active
className='gallery\sensitive gallery\curtain'
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
>
<span className='gallery\message'>
<strong className='gallery\warning'>
{sensitive ? (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
) : (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
)}
</strong>
<FormattedMessage
defaultMessage='Click to view'
id='status.sensitive_toggle'
/>
</span>
</CommonButton>
); // No children with hidden media
// If our media is visible, then we render it alongside the
// "eyeball" button.
} else {
button = (
<CommonButton
className='gallery\sensitive gallery\button'
icon={visible ? 'eye' : 'eye-slash'}
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
/>
);
// If our first item is a video, we render a player. Otherwise,
// we render our images.
if (attachments.getIn([0, 'type']) === 'video') {
size = 1;
children = (
<StatusContentGalleryPlayer
attachment={attachments.get(0)}
autoPlayGif={autoPlayGif}
intl={intl}
letterbox={letterbox}
onClick={handleVideoClick}
/>
);
} else {
size = useableAttachments.size;
children = useableAttachments.map(
(attachment, index) => (
<StatusContentGalleryItem
attachment={attachment}
autoPlayGif={autoPlayGif}
gallerySize={size}
index={index}
intl={intl}
key={attachment.get('id')}
letterbox={letterbox}
onClick={handleMediaClick}
/>
)
);
}
}
// Renders the gallery.
return (
<div
className={computedClass}
style={{ height: `${this.props.height}px` }}
>
{button}
{children}
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
// <StatusContentGalleryItem>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/item
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
expand: { id: 'media_gallery.expand', defaultMessage: 'Expand image' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryItem extends ImmutablePureComponent {
// Props.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
gallerySize: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
};
// Click handling.
handleClick = this.props.onClick.bind(this, this.props.index);
// Item rendering.
render () {
const { handleClick } = this;
const {
attachment,
autoPlayGif,
gallerySize,
intl,
letterbox,
} = this.props;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let thumbnail = '';
const computedClass = classNames('glitch', 'glitch__status__content__gallery__item', {
_letterbox: letterbox,
});
// If our gallery has more than one item, our images only take up
// half the width. We need this for image `sizes` calculations.
let multiplier = gallerySize === 1 ? 1 : .5;
// Image attachments
if (attachment.get('type') === 'image') {
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
// This lets the browser conditionally select the preview or
// original image depending on what the rendered size ends up
// being. We, of course, have no way of knowing what the width
// of the gallery will be postCSS, but we conservatively roll
// with 400px. (Note: Upstream Mastodon used media queries here,
// but because our page layout is user-configurable, we don't
// bother.)
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `${(400 * multiplier) >> 0}px`;
// The image.
thumbnail = (
<img
alt=''
className='item\image'
sizes={sizes}
src={previewUrl}
srcSet={srcSet}
/>
);
// Gifv attachments.
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<video
autoPlay={autoPlay}
className='item\gifv'
loop
muted
poster={previewUrl}
src={originalUrl}
/>
);
}
// Rendering. We render the item inside of a button+link, which
// provides the original. (We can do this for gifvs because we
// don't show the controls.)
return (
<CommonButton
className={computedClass}
data-gallery-size={gallerySize}
href={remoteUrl || originalUrl}
key={attachment.get('id')}
onClick={handleClick}
title={intl.formatMessage(messages.expand)}
>{thumbnail}</CommonButton>
);
}
}

View File

@@ -0,0 +1,88 @@
@import 'variables';
.glitch.glitch__status__content__gallery__item {
display: inline-block;
position: relative;
width: 100%;
height: 100%;
cursor: zoom-in;
.item\\image,
.item\\gifv {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.letterbox {
.item\\image,
.item\\gifv {
width: auto;
height: auto;
object-fit: fill;
}
}
&[data-gallery-size="2"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:last-child {
margin: .375em .375em .375em .1875em;
}
}
&[data-gallery-size="3"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:nth-last-child(2) {
float: right;
height: calc(50% - .5625em);
margin: .375em .375em .1875em .1875em;
}
&:last-child {
float: right;
height: calc(50% - .5625em);
margin: .1875em .375em .1875em .375em;
}
}
&[data-gallery-size="4"] {
width: calc(50% - .5625em);
height: calc(50% - .5625em);
margin: .375em .1875em .1875em .375em;
&:nth-last-child(3) {
margin: .375em .375em .1875em .1875em;
}
&:nth-last-child(2) {
margin: .1875em .1875em .375em .375em;
}
&:last-child {
margin: .1875em .375em .375em .1875em;
}
}
}
// add GIF label in CSS

View File

@@ -0,0 +1,233 @@
// <StatusContentGalleryPlayer>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/player
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
mute: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
open: { id: 'video_player.open', defaultMessage: 'Open video' },
play: { id: 'video_player.play', defaultMessage: 'Play video' },
pause: { id: 'video_player.pause', defaultMessage: 'Pause video' },
expand: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryPlayer extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
}
state = {
hasAudio: true,
muted: true,
preview: !isIOS() && this.props.autoPlayGif,
videoError: false,
}
// Basic video controls.
handleMute = () => {
this.setState({ muted: !this.state.muted });
}
handlePlayPause = () => {
const { video } = this;
if (video.paused) {
video.play();
} else {
video.pause();
}
}
// When clicking we either open (de-preview) the video or we
// expand it, depending. Note that when we de-preview the video will
// also begin playing (except on iOS) due to its `autoplay`
// attribute.
handleClick = () => {
const { setState, video } = this;
const { onClick } = this.props;
const { preview } = this.state;
if (preview) setState({ preview: false });
else {
video.pause();
onClick(video.currentTime);
}
}
// Loading and errors. We have to do some hacks in order to check if
// the video has audio imo. There's probably a better way to do this
// but that's how upstream has it.
handleLoadedData = () => {
const { video } = this;
if (('WebkitAppearance' in document.documentElement.style && video.audioTracks.length === 0) || video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
// On mounting or update, we ensure our video has the needed event
// listeners. We can't necessarily do this right away because there
// might be a preview up.
componentDidMount () {
this.componentDidUpdate();
}
componentDidUpdate () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('error', handleVideoError);
}
// On unmounting, we remove the listeners from the video element.
componentWillUnmount () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.removeEventListener('loadeddata', handleLoadedData);
video.removeEventListener('error', handleVideoError);
}
// Getting a reference to our video.
setRef = (c) => {
this.video = c;
}
// Rendering.
render () {
const {
handleClick,
handleMute,
handlePlayPause,
setRef,
video,
} = this;
const {
attachment,
letterbox,
intl,
} = this.props;
const {
hasAudio,
muted,
preview,
videoError,
} = this.state;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let content = null;
const computedClass = classNames('glitch', 'glitch__status__content__gallery__player', {
_letterbox: letterbox,
});
// This gets our content: either a preview image, an error
// message, or the video.
switch (true) {
case preview:
content = (
<img
alt=''
className='player\preview'
src={previewUrl}
/>
);
break;
case videoError:
content = (
<span className='player\error'>
<FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' />
</span>
);
break;
default:
content = (
<video
autoPlay={!isIOS()}
className='player\video'
loop
muted={muted}
poster={previewUrl}
ref={setRef}
src={originalUrl}
/>
);
break;
}
// Everything goes inside of a button because everything is a
// button. This is okay wrt the video element because it doesn't
// have controls.
return (
<div className={computedClass}>
<CommonButton
className='player\box'
href={remoteUrl || originalUrl}
key='box'
onClick={handleClick}
title={intl.formatMessage(preview ? messages.open : messages.expand)}
>{content}</CommonButton>
{!preview ? (
<CommonButton
active={!video.paused}
className='player\play-pause player\button'
icon={video.paused ? 'play' : 'pause'}
key='play'
onClick={handlePlayPause}
title={intl.formatMessage(messages.play)}
/>
) : null}
{!preview && hasAudio ? (
<CommonButton
active={!muted}
className='player\mute player\button'
icon={muted ? 'volume-off' : 'volume-up'}
key='mute'
onClick={handleMute}
title={intl.formatMessage(messages.mute)}
/>
) : null}
</div>
);
}
}

View File

@@ -0,0 +1,71 @@
@import 'variables';
.glitch.glitch__status__content__gallery__player {
display: block;
padding: (1.5em * 1.35) 0; // Creates black bars at the bottom/top
width: 100%;
height: calc(100% - (1.5em * 1.35 * 2));
cursor: zoom-in;
.player\\box {
display: block;
position: relative;
width: 100%;
height: 100%;
& > img,
& > video {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.player\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&.player\\play-pause {
bottom: 0;
left: 0;
}
&.player\\mute {
bottom: 0;
right: 0;
}
}
&._letterbox {
.player\\box {
& > img,
& > video {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -0,0 +1,74 @@
@import 'variables';
.glitch.glitch__status__content__gallery {
display: block;
position: relative;
color: $ui-primary-color;
background: $base-shadow-color;
.gallery\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&:hover {
opacity: 1;
}
&.gallery\\sensitive {
top: 0;
left: 0;
}
}
.gallery\\curtain.gallery\\sensitive {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 0;
padding: 0;
color: $ui-secondary-color;
background: $base-overlay-background;
font-size: (1.25em / 1.35); // approx. .925em
line-height: 1.35;
text-align: center;
white-space: nowrap;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in;
.gallery\\message {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 2.6em;
margin: auto;
.gallery\\warning {
display: block;
font-size: (1.35em / 1.25);
line-height: 1.35;
}
}
&:active,
&:focus,
&:hover {
color: $primary-text-color;
background: $base-overlay-background; // No change
transition: color ($glitch-animation-speed * .3s) ease-out;
}
}
}

View File

@@ -0,0 +1,520 @@
// <StatusContent>
// ===============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isRtl } from 'mastodon/rtl';
// Our imports.
import StatusContentCard from './card';
import StatusContentGallery from './gallery';
import StatusContentUnknown from './unknown';
import CommonButton from 'glitch/components/common/button';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
card_link :
{ id: 'status.card', defaultMessage: 'Card' },
video_link :
{ id: 'status.video', defaultMessage: 'Video' },
image_link :
{ id: 'status.image', defaultMessage: 'Image' },
unknown_link :
{ id: 'status.unknown_attachment', defaultMessage: 'Unknown attachment' },
hashtag :
{ id: 'status.hashtag', defaultMessage: 'Hashtag @{name}' },
show_more :
{ id: 'status.show_more', defaultMessage: 'Show more' },
show_less :
{ id: 'status.show_less', defaultMessage: 'Show less' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContent extends ImmutablePureComponent {
// Props and state.
static propTypes = {
autoPlayGif: PropTypes.bool,
detailed: PropTypes.bool,
expanded: PropTypes.oneOf([true, false, null]),
handler: PropTypes.object.isRequired,
hideMedia: PropTypes.bool,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func,
onHeightUpdate: PropTypes.func,
setExpansion: PropTypes.func,
status: ImmutablePropTypes.map.isRequired,
}
state = {
hidden: true,
}
// Variables.
text = null
// Our constructor preprocesses our status content and turns it into
// an array of React elements, stored in `this.text`.
constructor (props) {
super(props);
const { intl, history, status } = props;
// This creates a document fragment with the DOM contents of our
// status's text and a TreeWalker to walk them.
const range = document.createRange();
range.selectNode(document.body);
const walker = document.createTreeWalker(
range.createContextualFragment(status.get('contentHtml')),
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{ acceptNode (node) {
const name = node.nodeName;
switch (true) {
case node.parentElement && node.parentElement.nodeName.toUpperCase() === 'A':
return NodeFilter.FILTER_REJECT; // No link children
case node.nodeType === Node.TEXT_NODE:
case name.toUpperCase() === 'A':
case name.toUpperCase() === 'P':
case name.toUpperCase() === 'BR':
case name.toUpperCase() === 'IMG': // Emoji
return NodeFilter.FILTER_ACCEPT;
default:
return NodeFilter.FILTER_SKIP;
}
} },
);
const attachments = status.get('attachments');
const card = (!attachments || !attachments.size) && status.get('card');
this.text = [];
let currentP = [];
// This walks the contents of our status.
while (walker.nextNode()) {
const node = walker.currentNode;
const nodeName = node.nodeName.toUpperCase();
switch (nodeName) {
// If our element is a link, then we process it here.
case 'A':
currentP.push((() => {
// Here we detect what kind of link we're dealing with.
let mention = status.get('mentions') ? status.get('mentions').find(
item => node.href === item.get('url')
) : null;
let tag = status.get('tags') ? status.get('tags').find(
item => node.href === item.get('url')
) : null;
let attachment = attachments ? attachments.find(
item => node.href === item.get('url') || node.href === item.get('text_url') || node.href === item.get('remote_url')
) : null;
let text = node.textContent;
let icon = '';
let type = '';
// We use a switch to select our link type.
switch (true) {
// This handles cards.
case card && node.href === card.get('url'):
text = card.get('title') || intl.formatMessage(messages.card);
icon = 'id-card-o';
return (
<CommonButton
className={'content\card content\button'}
href={node.href}
icon={icon}
key={currentP.length}
showTitle
title={text}
/>
);
// This handles mentions.
case mention && (text.replace(/^@/, '') === mention.get('username') || text.replace(/^@/, '') === mention.get('acct')):
icon = text[0] === '@' ? '@' : '';
text = mention.get('acct').split('@');
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
return (
<CommonLink
className='content\mention content\link'
destination={`/accounts/${mention.get('id')}`}
history={history}
href={node.href}
key={currentP.length}
title={'@' + mention.get('acct')}
>
{icon ? <span className='content\at'>{icon}</span> : null}
<span className='content\username'>{text[0]}</span>
{text[1] ? <span className='content\at'>@</span> : null}
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
</CommonLink>
);
// This handles attachment links.
case !!attachment:
type = attachment.get('type');
switch (type) {
case 'unknown':
text = intl.formatMessage(messages.unknown_attachment);
icon = 'question';
break;
case 'video':
text = intl.formatMessage(messages.video);
icon = 'video-camera';
break;
default:
text = intl.formatMessage(messages.image);
icon = 'picture-o';
break;
}
return (
<CommonButton
className={`content\\${type} content\\button`}
href={node.href}
icon={icon}
key={currentP.length}
showTitle
title={text}
/>
);
// This handles hashtag links.
case !!tag && (text.replace(/^#/, '') === tag.get('name')):
icon = text[0] === '#' ? '#' : '';
text = tag.get('name');
return (
<CommonLink
className='content\tag content\link'
destination={`/timelines/tag/${tag.get('name')}`}
history={history}
href={node.href}
key={currentP.length}
title={intl.formatMessage(messages.hashtag, { name: tag.get('name') })}
>
{icon ? <span className='content\hash'>{icon}</span> : null}
<span className='content\tagname'>{text}</span>
</CommonLink>
);
// This handles all other links.
default:
if (text === node.href && text.length > 23) {
text = text.substr(0, 22) + '…';
}
return (
<CommonLink
className='content\link'
href={node.href}
key={currentP.length}
title={node.href}
>{text}</CommonLink>
);
}
})());
break;
// If our element is an IMG, we only render it if it's an emoji.
case 'IMG':
if (!node.classList.contains('emojione')) break;
currentP.push(
<img
alt={node.alt}
className={'content\emojione'}
draggable={false}
key={currentP.length}
src={node.src}
{...(node.title ? { title: node.title } : {})}
/>
);
break;
// If our element is a BR, we pass it along.
case 'BR':
currentP.push(<br key={currentP.length} />);
break;
// If our element is a P, then we need to start a new paragraph.
// If our paragraph has content, we need to push it first.
case 'P':
if (currentP.length) this.text.push(
<p key={this.text.length}>
{currentP}
</p>
);
currentP = [];
break;
// Otherwise we just push the text.
default:
currentP.push(node.textContent);
}
}
// If there is unpushed paragraph content after walking the entire
// status contents, we push it here.
if (currentP.length) this.text.push(
<p key={this.text.length}>
{currentP}
</p>
);
}
// When our content changes, we need to update the height of the
// status.
componentDidUpdate () {
if (this.props.onHeightUpdate) {
this.props.onHeightUpdate();
}
}
// When the mouse is pressed down, we grab its position.
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
// When the mouse is raised, we handle the click if it wasn't a part
// of a drag.
handleMouseUp = (e) => {
const { startXY } = this;
const { onClick } = this.props;
const { button, clientX, clientY, target } = e;
// This gets the change in mouse position. If `startXY` isn't set,
// it means that the click originated elsewhere.
if (!startXY) return;
const [ deltaX, deltaY ] = [clientX - startXY[0], clientY - startXY[1]];
// This switch prevents an overly lengthy if.
switch (true) {
// If the button being released isn't the main mouse button, or if
// we don't have a click parsing function, or if the mouse has
// moved more than 5px, OR if the target of the mouse event is a
// button or a link, we do nothing.
case button !== 0:
case !onClick:
case Math.sqrt(deltaX ** 2 + deltaY ** 2) >= 5:
case (
target.matches || target.msMatchesSelector || target.webkitMatchesSelector || (() => void 0)
).call(target, 'button, button *, a, a *'):
break;
// Otherwise, we parse the click.
default:
onClick(e);
break;
}
// This resets our mouse location.
this.startXY = null;
}
// This expands and collapses our spoiler.
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.setExpansion) {
this.props.setExpansion(this.props.expanded ? null : true);
} else {
this.setState({ hidden: !this.state.hidden });
}
}
// Renders our component.
render () {
const {
handleMouseDown,
handleMouseUp,
handleSpoilerClick,
text,
} = this;
const {
autoPlayGif,
detailed,
expanded,
handler,
hideMedia,
intl,
letterbox,
onClick,
setExpansion,
status,
} = this.props;
const attachments = status.get('attachments');
const card = status.get('card');
const hidden = setExpansion ? !expanded : this.state.hidden;
const computedClass = classNames('glitch', 'glitch__status__content', {
_actionable: !detailed && onClick,
_rtl: isRtl(status.get('search_index')),
});
let media = null;
let mediaIcon = '';
// This defines our media.
if (!hideMedia) {
// If there aren't any attachments, we try showing a card.
if ((!attachments || !attachments.size) && card) {
media = (
<StatusContentCard
card={card}
className='content\attachments content\card'
fullwidth={detailed}
letterbox={letterbox}
/>
);
mediaIcon = 'id-card-o';
// If any of the attachments are of unknown type, we render an
// unknown attachments list.
} else if (attachments && attachments.some(
(item) => item.get('type') === 'unknown'
)) {
media = (
<StatusContentUnknown
attachments={attachments}
className='content\attachments content\unknown'
fullwidth={detailed}
/>
);
mediaIcon = 'question';
// Otherwise, we display the gallery.
} else if (attachments) {
media = (
<StatusContentGallery
attachments={attachments}
autoPlayGif={autoPlayGif}
className='content\attachments content\gallery'
fullwidth={detailed}
intl={intl}
letterbox={letterbox}
onOpenMedia={handler.openMedia}
onOpenVideo={handler.openVideo}
sensitive={status.get('sensitive')}
standalone={!history}
/>
);
mediaIcon = attachments.getIn([0, 'type']) === 'video' ? 'film' : 'picture-o';
}
}
// Spoiler stuff.
if (status.get('spoiler_text').length > 0) {
// This gets our list of mentions.
const mentionLinks = status.get('mentions').map(mention => {
const text = mention.get('acct').split('@');
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
return (
<CommonLink
className='content\mention content\link'
destination={`/accounts/${mention.get('id')}`}
history={history}
href={mention.get('url')}
key={mention.get('id')}
title={'@' + mention.get('acct')}
>
<span className='content\at'>@</span>
<span className='content\username'>{text[0]}</span>
{text[1] ? <span className='content\at'>@</span> : null}
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
</CommonLink>
);
}).reduce((aggregate, item) => [...aggregate, ' ', item], []);
// Component rendering.
return (
<div className={computedClass}>
<div
className='content\spoiler'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>
<p>
<span
className='content\warning'
dangerouslySetInnerHTML={status.get('spoilerHtml')}
/>
{' '}
<CommonButton
active={!hidden}
className='content\showmore'
icon={hidden && mediaIcon}
onClick={handleSpoilerClick}
showTitle={hidden}
title={intl.formatMessage(messages.show_more)}
>
{hidden ? null : (
<FormattedMessage {...messages.show_less} />
)}
</CommonButton>
</p>
</div>
{hidden ? mentionLinks : null}
<div className='content\contents' hidden={hidden}>
<div
className='content\text'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>{text}</div>
{media}
</div>
</div>
);
// Non-spoiler statuses.
} else {
return (
<div className={computedClass}>
<div className='content\contents'>
<div
className='content\text'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>{text}</div>
{media}
</div>
</div>
);
}
}
}

View File

@@ -0,0 +1,101 @@
@import 'variables';
.glitch.glitch__status__content {
position: relative;
padding: (.75em * 1.35) .75em;
color: $primary-text-color;
direction: ltr; // but see `&.rtl` below
word-wrap: break-word;
overflow: visible;
white-space: pre-wrap;
.content\\contents {
.content\\attachments {
.content\\text + & {
margin-top: (.75em * 1.35);
}
}
&[hidden] {
display: none;
}
.content\\spoiler + & {
margin-top: (.75em * 1.35);
}
}
.content\\emojione {
width: 1.2em;
height: 1.2em;
}
.content\\spoiler,
.content\\text { // text-containing elements
p {
margin-bottom: (.75em * 1.35);
&:last-child {
margin-bottom: 0;
}
}
.content\\link {
color: $ui-secondary-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
/*
For mentions, we only underline the username and instance (not
the @'s).
*/
&.content\\mention {
.content\\at {
color: $glitch-lighter-color;
}
&:hover {
text-decoration: none;
.content\\instance,
.content\\username {
text-decoration: underline;
}
}
}
/*
Similarly, for tags, we only underline the tag name (not the
hash).
*/
&.content\\tag {
.content\\hash {
color: $glitch-lighter-color;
}
&:hover {
text-decoration: none;
.content\\tagname {
text-decoration: underline;
}
}
}
}
}
&._actionable {
.content\\text,
.content\\spoiler {
cursor: pointer;
}
}
&._rtl {
direction: rtl;
}
}

View File

@@ -0,0 +1,70 @@
// <StatusContentUnknown>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/unknown
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class StatusContentUnknown extends ImmutablePureComponent {
// Props.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
fullwidth: PropTypes.bool,
}
render () {
const { attachments, fullwidth } = this.props;
const computedClass = classNames('glitch', 'glitch__status__content__unknown', {
_fullwidth: fullwidth,
});
return (
<ul className={computedClass}>
{attachments.map(attachment => (
<li
className='unknown\attachment'
key={attachment.get('id')}
>
<CommonLink
className='unknown\link'
href={attachment.get('remote_url')}
>
<CommonIcon
className='unknown\icon'
name='link'
/>
{attachment.get('title') || attachment.get('remote_url')}
</CommonLink>
</li>
))}
</ul>
);
}
}

View File

@@ -0,0 +1,141 @@
// <StatusFooter>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/footer
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedDate } from 'react-intl';
// Mastodon imports.
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
import CommonSeparator from 'glitch/components/common/separator';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
public :
{ id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted :
{ id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private :
{ id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct :
{ id: 'privacy.direct.short', defaultMessage: 'Direct' },
permalink:
{ id: 'status.permalink', defaultMessage: 'Permalink' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusFooter extends ImmutablePureComponent {
// Props.
static propTypes = {
application: ImmutablePropTypes.map.isRequired,
datetime: PropTypes.string,
detailed: PropTypes.bool,
href: PropTypes.string,
intl: PropTypes.object.isRequired,
visibility: PropTypes.string,
}
// Rendering.
render () {
const { application, datetime, detailed, href, intl, visibility } = this.props;
const visibilityIcon = {
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[visibility];
const computedClass = classNames('glitch', 'glitch__status__footer', {
_detailed: detailed,
});
// If our status isn't detailed, our footer only contains the
// relative timestamp and visibility information.
if (!detailed) return (
<footer className={computedClass}>
<CommonLink
className='footer\timestamp footer\link'
href={href}
title={intl.formatMessage(messages.permalink)}
><RelativeTimestamp timestamp={datetime} /></CommonLink>
<CommonSeparator className='footer\separator' visible />
<CommonIcon
className='footer\icon'
name={visibilityIcon}
proportional
title={intl.formatMessage(messages[visibility])}
/>
</footer>
);
// Otherwise, we give the full timestamp and include a link to the
// application which posted the status if applicable.
return (
<footer className={computedClass}>
<CommonLink
className='footer\timestamp'
href={href}
title={intl.formatMessage(messages.permalink)}
>
<FormattedDate
value={new Date(datetime)}
hour12={false}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</CommonLink>
<CommonSeparator className='footer\separator' visible={!!application} />
{
application ? (
<CommonLink
className='footer\application footer\link'
href={application.get('website')}
>{application.get('name')}</CommonLink>
) : null
}
<CommonSeparator className='footer\separator' visible />
<CommonIcon
name={visibilityIcon}
className='footer\icon'
proportional
title={intl.formatMessage(messages[visibility])}
/>
</footer>
);
}
}

View File

@@ -0,0 +1,18 @@
@import 'variables';
.glitch.glitch__status__footer {
display: block;
height: 1.25em;
font-size: (1.25em / 1.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.footer\\link {
color: inherit;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,76 @@
// <StatusHeader>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/header
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import CommonAvatar from 'glitch/components/common/avatar';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component:
// --------------
export default class StatusHeader extends ImmutablePureComponent {
// Props.
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
comrade: ImmutablePropTypes.map,
history: PropTypes.object,
};
// Renders our component.
render () {
const {
account,
comrade,
history,
} = this.props;
// This displays our header.
return (
<header className='glitch glitch__status__header'>
<CommonLink
className='header\link'
destination={`/accounts/${account.get('id')}`}
history={history}
href={account.get('url')}
>
<CommonAvatar
account={account}
className='header\avatar'
comrade={comrade}
/>
</CommonLink>
<b
className='header\display-name'
dangerouslySetInnerHTML={{
__html: account.get('display_name_html'),
}}
/>
<code className='header\account'>@{account.get('acct')}</code>
</header>
);
}
}

View File

@@ -0,0 +1,45 @@
@import 'variables';
.glitch.glitch__status__header {
display: block;
height: 3.35em;
/*
Note that the computed value of `em` changes for `.account`, since it
has a different font-size.
*/
.header\\account,
.header\\display-name {
display: block;
border: none; // masto compat.
padding: 0; // masto compat.
max-width: none; // masto compat.
height: 1.35em;
overflow: hidden;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
This means that the heights of the account and display name together
are 2.6em.
*/
.header\\account {
font-size: (1.25em / 1.35); // approx. .925em
}
.header\\avatar {
float: left;
margin-right: .75em;
width: 3.35em;
height: 3.35em;
}
.header\\display-name {
padding-top: .75em;
}
}

View File

@@ -0,0 +1,392 @@
// <Status>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
// Our imports.
import StatusActionBar from './action_bar';
import StatusContent from './content';
import StatusFooter from './footer';
import StatusHeader from './header';
import StatusMissing from './missing';
import StatusNav from './nav';
import StatusPrepend from './prepend';
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
detailed:
{ id: 'status.detailed', defaultMessage: 'Detailed view' },
});
// * * * * * * * //
// The component
// -------------
export default class Status extends ImmutablePureComponent {
// Props, and state.
static propTypes = {
autoPlayGif: PropTypes.bool,
comrade: ImmutablePropTypes.map,
deleteModal: PropTypes.bool,
detailed: PropTypes.bool,
handler: PropTypes.objectOf(PropTypes.func).isRequired,
history: PropTypes.object,
index: PropTypes.number,
id: PropTypes.number,
listLength: PropTypes.number,
me: PropTypes.number,
muted: PropTypes.bool,
prepend: PropTypes.string,
reblogModal: PropTypes.bool,
setDetail: PropTypes.func,
settings: ImmutablePropTypes.map,
status: ImmutablePropTypes.map,
intersectionObserverWrapper: PropTypes.object,
intl : PropTypes.object,
}
state = {
isExpanded: null,
isIntersecting: true,
isHidden: false,
}
// Instance variables.
componentMounted = false;
// Prior to mounting, we fetch the status's card if this is a
// detailed status and we don't already have it.
componentWillMount () {
const { detailed, handler, status } = this.props;
if (!status.get('card') && detailed) handler.fetchCard(status);
}
// On mounting, we start up our intersection observer.
// `componentMounted` tells us everything worked out OK.
componentDidMount () {
const { handleIntersection, node } = this;
const { id, intersectionObserverWrapper } = this.props;
if (!intersectionObserverWrapper) return;
else intersectionObserverWrapper.observe(
id,
node,
handleIntersection
);
this.componentMounted = true;
}
// If the status is about to be both offscreen (not intersecting)
// and hidden, then we don't bother updating unless it's not already
// that way currently. Alternatively, if we're moving from offscreen
// to onscreen, we *have* to re-render. As a default case we just
// rely on `updateOnProps` and `updateOnStates` via the
// built-in `shouldComponentUpdate()` function.
shouldComponentUpdate (nextProps, nextState) {
switch (true) {
case !nextState.isIntersecting && nextState.isHidden:
switch (true) {
case this.state.isIntersecting:
case !this.state.isHidden:
case nextProps.listLength !== this.props.listLength:
case nextProps.index !== this.props.index:
return true;
default:
return false;
}
case nextState.isIntersecting && !this.state.isIntersecting:
return true;
default:
return super.shouldComponentUpdate(nextProps, nextState);
}
}
// If our component is about to update and is detailed, we request
// its card if we don't have it.
componentWillUpdate (nextProps) {
const { detailed, handler, status } = this.props;
if (!status.get('card') && nextProps.detailed && !detailed) {
handler.fetchCard(status);
}
}
// If the component is updated for any reason we save the height.
componentDidUpdate () {
const { isHidden, isIntersecting } = this.state;
if (isIntersecting || !isHidden) this.saveHeight();
}
// If our component is about to unmount, we'd better unset
// `componentMounted` lol.
componentWillUnmount () {
const { node } = this;
const { id, intersectionObserverWrapper } = this.props;
intersectionObserverWrapper.unobserve(id, node);
this.componentMounted = false;
}
// Doesn't quite work on Edge 15 but it gets close. This tells us if
// our status is onscreen, and if not we hide it at the next
// available opportunity. This isn't a huge deal (but it saves some
// rendering cycles if we don't have as much DOM) so we schedule
// it using `scheduleIdleTask`.
handleIntersection = (entry) => {
const isIntersecting = (
typeof entry.isIntersecting === 'boolean' ?
entry.isIntersecting :
entry.intersectionRect.height > 0
);
this.setState((prevState) => {
if (prevState.isIntersecting && !isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting,
isHidden: false,
};
});
}
// Because we scheduled toot-hiding as an idle task (see above), we
// *do* need to ensure that it's still not intersecting before we
// hide it lol.
hideIfNotIntersecting = () => {
if (!this.componentMounted) return;
this.setState((prevState) => ({
isHidden: !prevState.isIntersecting,
}));
}
// `saveHeight()` saves the status height so that we preserve its
// dimensions when it's being hidden.
saveHeight = () => {
if (this.node && this.node.children.length) {
this.height = this.node.getBoundingClientRect().height;
}
}
// `setExpansion` handles expanding and collapsing statuses. Note
// that `isExpanded` is a *trinary* value:
setExpansion = (value) => {
const { detailed } = this.props;
switch (true) {
// A value of `null` or `undefined` means the status should be
// neither expanded or collapsed.
case value === undefined || value === null:
this.setState({ isExpanded: null });
break;
// A value of `false` means that the status should be collapsed.
case !value:
if (!detailed) this.setState({ isExpanded: false });
else this.setState({ isExpanded: null }); // fallback
break;
// A value of `true` means that the status should be expanded.
case !!value:
this.setState({ isExpanded: true });
break;
}
}
// Stores our node and saves its height.
handleRef = (node) => {
this.node = node;
this.saveHeight();
}
// `handleClick()` handles all clicking stuff. We route links and
// make our status detailed if it isn't already.
handleClick = (e) => {
const { detailed, history, id, setDetail, status } = this.props;
if (!history || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
if (setDetail) setDetail(detailed ? null : id);
else history.push(`/statuses/${status.get('id')}`);
e.preventDefault();
}
// Puts our element on the screen.
render () {
const {
handleRef,
handleClick,
saveHeight,
setExpansion,
} = this;
const {
autoPlayGif,
comrade,
detailed,
handler,
history,
index,
intl,
listLength,
me,
muted,
prepend,
setDetail,
settings,
status,
} = this.props;
const {
isExpanded,
isHidden,
isIntersecting,
} = this.state;
let account = status.get('account');
let computedClass = 'glitch glitch__status';
let conditionalProps = {};
let selectorAttribs = {};
// If there's no status, we can't render lol.
if (status === null) {
return <StatusMissing />;
}
// Here are extra data-* attributes for use with CSS selectors.
// We don't use these but users can via UserStyles.
selectorAttribs = {
'data-status-by': `@${account.get('acct')}`,
};
if (prepend && comrade) {
selectorAttribs[`data-${prepend === 'favourite' ? 'favourited' : 'boosted'}-by`] = `@${comrade.get('acct')}`;
}
// If our index and list length have been set, we can set the
// corresponding ARIA attributes.
if (isFinite(index) && isFinite(listLength)) conditionalProps = {
'aria-posinset': index,
'aria-setsize': listLength,
};
// This sets our class names.
computedClass = classNames('glitch', 'glitch__status', {
_detailed: detailed,
_muted: muted,
}, `_${status.get('visibility')}`);
// If our status is offscreen and hidden, we render an empty div.
if (!isIntersecting && isHidden) {
return (
<article
{...conditionalProps}
data-id={status.get('id')}
ref={handleRef}
style={{
height: `${this.height}px`,
opacity: 0,
overflow: 'hidden',
visibility: 'hidden',
}}
tabIndex='0'
>
<div hidden>
{account.get('display_name') || account.get('username')}
{' '}
{status.get('content')}
</div>
</article>
);
}
// Otherwise, we can render our status!
return (
<article
className={computedClass}
{...conditionalProps}
data-id={status.get('id')}
ref={handleRef}
{...selectorAttribs}
tabIndex='0'
>
{prepend && comrade ? (
<StatusPrepend
comrade={comrade}
history={history}
type={prepend}
/>
) : null}
{setDetail ? (
<CommonButton
active={detailed}
className='status\detail status\button'
icon={detailed ? 'minus' : 'plus'}
onClick={handleClick}
title={intl.formatMessage(messages.detailed)}
/>
) : null}
<StatusHeader
account={account}
comrade={comrade}
history={history}
/>
<StatusContent
autoPlayGif={autoPlayGif}
detailed={detailed}
expanded={isExpanded}
handler={handler}
hideMedia={muted}
history={history}
intl={intl}
letterbox={settings.getIn(['media', 'letterbox'])}
onClick={handleClick}
onHeightUpdate={saveHeight}
setExpansion={setExpansion}
status={status}
/>
<StatusFooter
application={status.get('application')}
datetime={status.get('created_at')}
detailed={detailed}
href={status.get('url')}
intl={intl}
visibility={status.get('visibility')}
/>
<StatusActionBar
detailed={detailed}
handler={handler}
history={history}
intl={intl}
me={me}
status={status}
/>
{detailed ? (
<StatusNav id={status.get('id')} intl={intl} />
) : null}
</article>
);
}
}

View File

@@ -0,0 +1,33 @@
// <StatusMissing>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/missing
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import { FormattedMessage } from 'react-intl';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const StatusMissing = () => (
<div className='glitch glitch__status__missing'>
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
</div>
);
export default StatusMissing;

View File

@@ -0,0 +1,95 @@
// <StatusNav>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/nav
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
conversation:
{ id : 'status.view_conversation', defaultMessage : 'View conversation' },
reblogs:
{ id : 'status.view_reblogs', defaultMessage : 'View reblogs' },
favourites:
{ id : 'status.view_favourites', defaultMessage : 'View favourites' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusNav extends ImmutablePureComponent {
// Props.
static propTypes = {
id: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
}
// Rendering.
render () {
const { id, intl } = this.props;
return (
<nav className='glitch glitch__status__nav'>
<CommonLink
className='nav\conversation'
destination={`/statuses/${id}`}
title={intl.formatMessage(messages.conversation)}
>
<CommonIcon
className='nav\icon'
name='comments-o'
/>
</CommonLink>
<CommonLink
className='nav\reblogs'
destination={`/statuses/${id}/reblogs`}
title={intl.formatMessage(messages.reblogs)}
>
<CommonIcon
className='nav\icon'
name='retweet'
/>
</CommonLink>
<CommonLink
className='nav\favourites'
destination={`/statuses/${id}/favourites`}
title={intl.formatMessage(messages.favourites)}
>
<CommonIcon
className='nav\icon'
name='star'
/>
</CommonLink>
</nav>
);
}
}

View File

@@ -0,0 +1,99 @@
// <StatusPrepend>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/header
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class StatusPrepend extends React.PureComponent {
// Props.
static propTypes = {
comrade: ImmutablePropTypes.map.isRequired,
history: PropTypes.object,
type: PropTypes.string.isRequired,
};
// This is a quick functional React component to get the prepend
// message.
Message = () => {
const { comrade, history, type } = this.props;
let link = (
<CommonLink
className='prepend\comrade'
destination={`/accounts/${comrade.get('id')}`}
history={history}
href={comrade.get('url')}
>
{comrade.get('display_name_html') || comrade.get('username')}
</CommonLink>
);
switch (type) {
case 'favourite':
return (
<FormattedMessage
defaultMessage='{name} favourited your status'
id='notification.favourite'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
defaultMessage='{name} boosted your status'
id='notification.reblog'
values={{ name : link }}
/>
);
case 'reblogged':
return (
<FormattedMessage
defaultMessage='{name} boosted'
id='status.reblogged_by'
values={{ name : link }}
/>
);
}
return null;
}
// This renders the prepend icon and the prepend message in sequence.
render () {
const { Message } = this;
const { type } = this.props;
return type ? (
<aside className='glitch glitch__status__prepend'>
<CommonIcon
className={`prepend\\icon prepend\\${type}`}
name={type === 'favourite' ? 'star' : 'retweet'}
/>
<Message />
</aside>
) : null;
}
}

View File

@@ -0,0 +1,33 @@
@import 'variables';
.glitch.glitch__status__prepend {
display: block;
position: relative;
margin: 0 0 1em;
color: $ui-base-lighter-color;
padding: 0 0 0 (3.35em * .7);
.prepend\\icon {
display: block;
position: absolute;
margin: auto;
top: 0;
left: 0;
width: (3.35em * .7);
height: 1.35em;
text-align: center;
&.prepend\\reblog,
&.prepend\\reblogged {
color: $ui-highlight-color;
}
&.prepend\\favourite {
color: $gold-star;
}
}
.prepend\\comrade {
color: $glitch-lighter-color;
}
}

View File

@@ -0,0 +1,34 @@
@import 'variables';
.glitch.glitch__status {
display: block;
border-bottom: 1px solid $glitch-texture-color;
padding: (.75em * 1.35) .75em;
color: $ui-secondary-color;
font-size: medium;
line-height: 1.35;
cursor: default;
animation: fade 150ms linear;
@keyframes fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
/*
The detail button is styled to line up with the textual content of
status headers. See the `<StatusHeader>` CSS for more details on
their specific layout.
*/
.status\\detail.status\\button {
float: right;
width: 1.35em; // 2.6em of parent
height: 1.35em; // 2.6em of parent
font-size: (2.6em / 1.35); // approx. 1.925em
text-align: center;
}
&._direct:not(._muted) {
background: $glitch-texture-color;
}
}

View File

@@ -0,0 +1,42 @@
{
"getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.",
"layout.auto": "Auto",
"layout.current_is": "Your current layout is:",
"layout.desktop": "Desktop",
"layout.mobile": "Mobile",
"navigation_bar.app_settings": "App settings",
"onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.welcome": "Welcome to {domain}!",
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.",
"settings.auto_collapse": "Automatic collapsing",
"settings.auto_collapse_all": "Everything",
"settings.auto_collapse_lengthy": "Lengthy toots",
"settings.auto_collapse_media": "Toots with media",
"settings.auto_collapse_notifications": "Notifications",
"settings.auto_collapse_replies": "Replies",
"settings.close": "Close",
"settings.collapsed_statuses": "Collapsed toots",
"settings.enable_collapsed": "Enable collapsed toots",
"settings.general": "General",
"settings.image_backgrounds": "Image backgrounds",
"settings.image_backgrounds_media": "Preview collapsed toot media",
"settings.image_backgrounds_users": "Give collapsed toots an image background",
"settings.media": "Media",
"settings.media_letterbox": "Letterbox media",
"settings.media_fullwidth": "Full-width media previews",
"settings.preferences": "User preferences",
"settings.wide_view": "Wide view (Desktop mode only)",
"settings.navbar_under": "Navbar at the bottom (Mobile only)",
"status.collapse": "Collapse",
"status.uncollapse": "Uncollapse",
"notification.markForDeletion": "Mark for deletion",
"notifications.clear": "Clear all my notifications",
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
"notifications.marked_clear": "Clear selected notifications",
"notification_purge.btn_all": "Select\nall",
"notification_purge.btn_none": "Select\nnone",
"notification_purge.btn_invert": "Invert\nselection",
"notification_purge.btn_apply": "Clear\nselected"
}

View File

@@ -0,0 +1,124 @@
/*
`reducers/local_settings`
========================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
This file provides our Redux reducers related to local settings. The
associated actions are:
- __`STORE_HYDRATE` :__
Used to hydrate the store with its initial values.
- __`LOCAL_SETTING_CHANGE` :__
Used to change the value of a local setting in the store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { Map as ImmutableMap } from 'immutable';
// Mastodon imports //
import { STORE_HYDRATE } from '../../mastodon/actions/store';
// Our imports //
import { LOCAL_SETTING_CHANGE } from '../actions/local_settings';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
initialState:
-------------
You can see the default values for all of our local settings here.
These are only used if no previously-saved values exist.
*/
const initialState = ImmutableMap({
layout : 'auto',
stretch : true,
navbar_under : false,
collapsed : ImmutableMap({
enabled : true,
auto : ImmutableMap({
all : false,
notifications : true,
lengthy : true,
replies : false,
media : false,
}),
backgrounds : ImmutableMap({
user_backgrounds : false,
preview_images : false,
}),
}),
media : ImmutableMap({
letterbox : true,
fullwidth : true,
}),
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Helper functions:
-----------------
### `hydrate(state, localSettings)`
`hydrate()` is used to hydrate the `local_settings` part of our store
with its initial values. The `state` will probably just be the
`initialState`, and the `localSettings` should be whatever we pulled
from `localStorage`.
*/
const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`localSettings(state = initialState, action)`:
----------------------------------------------
This function holds our actual reducer.
If our action is `STORE_HYDRATE`, then we call `hydrate()` with the
`local_settings` property of the provided `action.state`.
If our action is `LOCAL_SETTING_CHANGE`, then we set `action.key` in
our state to the provided `action.value`. Note that `action.key` MUST
be an array, since we use `setIn()`.
> __Note :__
> We call this function `localSettings`, but its associated object
> in the store is `local_settings`.
*/
export default function localSettings(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('local_settings'));
case LOCAL_SETTING_CHANGE:
return state.setIn(action.key, action.value);
default:
return state;
}
};

View File

@@ -0,0 +1,7 @@
import { createStructuredSelector } from 'reselect';
const makeIntlSelector = () => createStructuredSelector({
intl: ({ intl }) => intl,
});
export default makeIntlSelector;

View File

@@ -0,0 +1,33 @@
import { createSelector } from 'reselect';
const makeStatusSelector = () => {
return createSelector(
[
(state, id) => state.getIn(['statuses', id]),
(state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
(state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
(state, id) => state.getIn(['cards', id], null),
],
(statusBase, statusReblog, accountBase, accountReblog, card) => {
if (!statusBase) {
return null;
}
if (statusReblog) {
statusReblog = statusReblog.set('account', accountReblog);
} else {
statusReblog = null;
}
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
map.set('account', accountBase);
map.set('card', card);
});
}
);
};
export default makeStatusSelector;

View File

@@ -0,0 +1,420 @@
/*
`util/bio_metadata`
===================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
This file provides two functions for dealing with bio metadata. The
functions are:
- __`processBio(content)` :__
Processes `content` to extract any frontmatter. The returned
object has two properties: `text`, which contains the text of
`content` sans-frontmatter, and `metadata`, which is an array
of key-value pairs (in two-element array format). If no
frontmatter was provided in `content`, then `metadata` will be
an empty array.
- __`createBio(note, data)` :__
Reverses the process in `processBio()`; takes a `note` and an
array of two-element arrays (which should give keys and values)
and outputs a string containing a well-formed bio with
frontmatter.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*********************************************************************\
To my lovely code maintainers,
The syntax recognized by the Mastodon frontend for its bio metadata
feature is a subset of that provided by the YAML 1.2 specification.
In particular, Mastodon recognizes metadata which is provided as an
implicit YAML map, where each key-value pair takes up only a single
line (no multi-line values are permitted). To simplify the level of
processing required, Mastodon metadata frontmatter has been limited
to only allow those characters in the `c-printable` set, as defined
by the YAML 1.2 specification, instead of permitting those from the
`nb-json` characters inside double-quoted strings like YAML proper.
¶ It is important to note that Mastodon only borrows the *syntax*
of YAML, not its semantics. This is to say, Mastodon won't make any
attempt to interpret the data it receives. `true` will not become a
boolean; `56` will not be interpreted as a number. Rather, each key
and every value will be read as a string, and as a string they will
remain. The order of the pairs is unchanged, and any duplicate keys
are preserved. However, YAML escape sequences will be replaced with
the proper interpretations according to the YAML 1.2 specification.
¶ The implementation provided below interprets `<br>` as `\n` and
allows for an open <p> tag at the beginning of the bio. It replaces
the escaped character entities `&apos;` and `&quot;` with single or
double quotes, respectively, prior to processing. However, no other
escaped characters are replaced, not even those which might have an
impact on the syntax otherwise. These minor allowances are provided
because the Mastodon backend will insert these things automatically
into a bio before sending it through the API, so it is important we
account for them. Aside from this, the YAML frontmatter must be the
very first thing in the bio, leading with three consecutive hyphen-
minues (`---`), and ending with the same or, alternatively, instead
with three periods (`...`). No limits have been set with respect to
the number of characters permitted in the frontmatter, although one
should note that only limited space is provided for them in the UI.
¶ The regular expression used to check the existence of, and then
process, the YAML frontmatter has been split into a number of small
components in the code below, in the vain hope that it will be much
easier to read and to maintain. I leave it to the future readers of
this code to determine the extent of my successes in this endeavor.
Sending love + warmth eternal,
- kibigo [@kibi@glitch.social]
\*********************************************************************/
/* "u" FLAG COMPATABILITY */
let compat_mode = false;
try {
new RegExp('.', 'u');
} catch (e) {
compat_mode = true;
}
/* CONVENIENCE FUNCTIONS */
const unirex = str => compat_mode ? new RegExp(str) : new RegExp(str, 'u');
const rexstr = exp => '(?:' + exp.source + ')';
/* CHARACTER CLASSES */
const DOCUMENT_START = /^/;
const DOCUMENT_END = /$/;
const ALLOWED_CHAR = unirex( // `c-printable` in the YAML 1.2 spec.
compat_mode ? '[\t\n\r\x20-\x7e\x85\xa0-\ufffd]' : '[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]'
);
const WHITE_SPACE = /[ \t]/;
const INDENTATION = / */; // Indentation must be only spaces.
const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/;
const ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/;
const HEXADECIMAL_CHARS = /[0-9a-fA-F]/;
const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/;
const FLOW_CHAR = /[,[\]{}]/;
/* NEGATED CHARACTER CLASSES */
const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]');
const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]');
const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]');
const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]');
const NOT_ALLOWED_CHAR = unirex(
'(?!' + rexstr(ALLOWED_CHAR) + ')[^]'
);
/* BASIC CONSTRUCTS */
const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*');
const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*');
const NEW_LINE = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
);
const SOME_NEW_LINES = unirex(
'(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
);
const POSSIBLE_STARTS = unirex(
rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
);
const POSSIBLE_ENDS = unirex(
rexstr(SOME_NEW_LINES) + '|' +
rexstr(DOCUMENT_END) + '|' +
rexstr(/<\/p>/)
);
const CHARACTER_ESCAPE = unirex(
rexstr(/\\/) +
'(?:' +
rexstr(ESCAPE_CHAR) + '|' +
rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
')'
);
const ESCAPED_CHAR = unirex(
rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
rexstr(CHARACTER_ESCAPE)
);
const ANY_ESCAPED_CHARS = unirex(
rexstr(ESCAPED_CHAR) + '*'
);
const ESCAPED_APOS = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
);
const ANY_ESCAPED_APOS = unirex(
rexstr(ESCAPED_APOS) + '*'
);
const FIRST_KEY_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')'
);
const FIRST_VALUE_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')'
// Flow indicators are allowed in values.
);
const LATER_KEY_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
);
const LATER_VALUE_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
// Flow indicators are allowed in values.
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
);
/* YAML CONSTRUCTS */
const YAML_START = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
);
const YAML_END = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
);
const YAML_LOOKAHEAD = unirex(
'(?=' +
rexstr(YAML_START) +
rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
')'
);
const YAML_DOUBLE_QUOTE = unirex(
rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
);
const YAML_SINGLE_QUOTE = unirex(
rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
);
const YAML_SIMPLE_KEY = unirex(
rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
);
const YAML_SIMPLE_VALUE = unirex(
rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
);
const YAML_KEY = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_KEY)
);
const YAML_VALUE = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_VALUE)
);
const YAML_SEPARATOR = unirex(
rexstr(ANY_WHITE_SPACE) +
':' + rexstr(WHITE_SPACE) +
rexstr(ANY_WHITE_SPACE)
);
const YAML_LINE = unirex(
'(' + rexstr(YAML_KEY) + ')' +
rexstr(YAML_SEPARATOR) +
'(' + rexstr(YAML_VALUE) + ')'
);
/* FRONTMATTER REGEX */
const YAML_FRONTMATTER = unirex(
rexstr(POSSIBLE_STARTS) +
rexstr(YAML_LOOKAHEAD) +
rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
'(?:' +
'(' + rexstr(INDENTATION) + ')' +
rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'(?:' +
'\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'){0,4}' +
')?' +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
);
/* SEARCHES */
const FIND_YAML_LINES = unirex(
rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
);
/* STRING PROCESSING */
function processString(str) {
switch (str.charAt(0)) {
case '"':
return str
.substring(1, str.length - 1)
.replace(/\\0/g, '\x00')
.replace(/\\a/g, '\x07')
.replace(/\\b/g, '\x08')
.replace(/\\t/g, '\x09')
.replace(/\\\x09/g, '\x09')
.replace(/\\n/g, '\x0a')
.replace(/\\v/g, '\x0b')
.replace(/\\f/g, '\x0c')
.replace(/\\r/g, '\x0d')
.replace(/\\e/g, '\x1b')
.replace(/\\ /g, '\x20')
.replace(/\\"/g, '\x22')
.replace(/\\\//g, '\x2f')
.replace(/\\\\/g, '\x5c')
.replace(/\\N/g, '\x85')
.replace(/\\_/g, '\xa0')
.replace(/\\L/g, '\u2028')
.replace(/\\P/g, '\u2029')
.replace(
new RegExp(
unirex(
rexstr(/\\x/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{2})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
)
.replace(
new RegExp(
unirex(
rexstr(/\\u/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{4})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
)
.replace(
new RegExp(
unirex(
rexstr(/\\U/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{8})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
);
case '\'':
return str
.substring(1, str.length - 1)
.replace(/''/g, '\'');
default:
return str;
}
}
/* BIO PROCESSING */
export function processBio(content) {
content = content.replace(/&quot;/g, '"').replace(/&apos;/g, '\'');
let result = {
text: content,
metadata: [],
};
let yaml = content.match(YAML_FRONTMATTER);
if (!yaml) return result;
else yaml = yaml[0];
let start = content.search(YAML_START);
let end = start + yaml.length - yaml.search(YAML_START);
result.text = content.substr(0, start) + content.substr(end);
let metadata = null;
let query = new RegExp(FIND_YAML_LINES, 'g');
while ((metadata = query.exec(yaml))) {
result.metadata.push([
processString(metadata[1]),
processString(metadata[2]),
]);
}
return result;
}
/* BIO CREATION */
export function createBio(note, data) {
if (!note) note = '';
let frontmatter = '';
if ((data && data.length) || note.match(/^\s*---\s+/)) {
if (!data) frontmatter = '---\n...\n';
else {
frontmatter += '---\n';
for (let i = 0; i < data.length; i++) {
let key = '' + data[i][0];
let val = '' + data[i][1];
// Key processing
if (key === (key.match(YAML_SIMPLE_KEY) || [])[0]) /* do nothing */;
else if (key.indexOf('\'') === -1 && key === (key.match(ANY_ESCAPED_APOS) || [])[0]) key = '\'' + key + '\'';
else {
key = key
.replace(/\x00/g, '\\0')
.replace(/\x07/g, '\\a')
.replace(/\x08/g, '\\b')
.replace(/\x0a/g, '\\n')
.replace(/\x0b/g, '\\v')
.replace(/\x0c/g, '\\f')
.replace(/\x0d/g, '\\r')
.replace(/\x1b/g, '\\e')
.replace(/\x22/g, '\\"')
.replace(/\x5c/g, '\\\\');
let badchars = key.match(
new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu')
) || [];
for (let j = 0; j < badchars.length; j++) {
key = key.replace(
badchars[i],
'\\u' + badchars[i].codePointAt(0).toLocaleString('en', {
useGrouping: false,
minimumIntegerDigits: 4,
})
);
}
key = '"' + key + '"';
}
// Value processing
if (val === (val.match(YAML_SIMPLE_VALUE) || [])[0]) /* do nothing */;
else if (val.indexOf('\'') === -1 && val === (val.match(ANY_ESCAPED_APOS) || [])[0]) val = '\'' + val + '\'';
else {
val = val
.replace(/\x00/g, '\\0')
.replace(/\x07/g, '\\a')
.replace(/\x08/g, '\\b')
.replace(/\x0a/g, '\\n')
.replace(/\x0b/g, '\\v')
.replace(/\x0c/g, '\\f')
.replace(/\x0d/g, '\\r')
.replace(/\x1b/g, '\\e')
.replace(/\x22/g, '\\"')
.replace(/\x5c/g, '\\\\');
let badchars = val.match(
new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu')
) || [];
for (let j = 0; j < badchars.length; j++) {
val = val.replace(
badchars[i],
'\\u' + badchars[i].codePointAt(0).toLocaleString('en', {
useGrouping: false,
minimumIntegerDigits: 4,
})
);
}
val = '"' + val + '"';
}
frontmatter += key + ': ' + val + '\n';
}
frontmatter += '...\n';
}
}
return frontmatter + note;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -22,6 +22,7 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
@@ -72,14 +73,16 @@ export function mentionCompose(account, router) {
export function submitCompose() {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
let status = getState().getIn(['compose', 'text'], '');
if (!status || !status.length) {
return;
}
dispatch(submitComposeRequest());
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
status = status + ' 👁️';
}
api(getState).post('/api/v1/statuses', {
status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
@@ -246,6 +249,13 @@ export function unmountCompose() {
};
};
export function toggleComposeAdvancedOption(option) {
return {
type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
option: option,
};
}
export function changeComposeSensitivity() {
return {
type: COMPOSE_SENSITIVITY_CHANGE,

View File

@@ -6,6 +6,17 @@ import { defineMessages } from 'react-intl';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
// tracking the notif cleaning request
export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS';
export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL';
export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes
// Unmark notifications (when the cleaning mode is left)
export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE';
// Mark one for delete
export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
@@ -187,3 +198,67 @@ export function scrollTopNotifications(top) {
top,
};
};
export function deleteMarkedNotifications() {
return (dispatch, getState) => {
dispatch(deleteMarkedNotificationsRequest());
let ids = [];
getState().getIn(['notifications', 'items']).forEach((n) => {
if (n.get('markedForDelete')) {
ids.push(n.get('id'));
}
});
if (ids.length === 0) {
return;
}
api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => {
dispatch(deleteMarkedNotificationsSuccess());
}).catch(error => {
console.error(error);
dispatch(deleteMarkedNotificationsFail(error));
});
};
};
export function enterNotificationClearingMode(yes) {
return {
type: NOTIFICATIONS_ENTER_CLEARING_MODE,
yes: yes,
};
};
export function markAllNotifications(yes) {
return {
type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
yes: yes, // true, false or null. null = invert
};
};
export function deleteMarkedNotificationsRequest() {
return {
type: NOTIFICATIONS_DELETE_MARKED_REQUEST,
};
};
export function deleteMarkedNotificationsFail() {
return {
type: NOTIFICATIONS_DELETE_MARKED_FAIL,
};
};
export function markNotificationForDelete(id, yes) {
return {
type: NOTIFICATION_MARK_FOR_DELETE,
id: id,
yes: yes,
};
};
export function deleteMarkedNotificationsSuccess() {
return {
type: NOTIFICATIONS_DELETE_MARKED_SUCCESS,
};
};

View File

@@ -64,6 +64,7 @@ export default class Avatar extends React.PureComponent {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
data-avatar-of={`@${account.get('acct')}`}
/>
);
}

View File

@@ -21,8 +21,8 @@ export default class AvatarOverlay extends React.PureComponent {
return (
<div className='account__avatar-overlay'>
<div className='account__avatar-overlay-base' style={baseStyle} />
<div className='account__avatar-overlay-overlay' style={overlayStyle} />
<div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} />
<div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} />
</div>
);
}

View File

@@ -7,6 +7,7 @@ export default class Column extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
extraClasses: PropTypes.string,
};
scrollTop () {
@@ -40,10 +41,10 @@ export default class Column extends React.PureComponent {
}
render () {
const { children } = this.props;
const { children, extraClasses } = this.props;
return (
<div role='region' className='column' ref={this.setRef}>
<div role='region' className={`column ${extraClasses || ''}`} ref={this.setRef}>
{children}
</div>
);

View File

@@ -9,7 +9,8 @@ export default class ColumnBackButton extends React.PureComponent {
};
handleClick = () => {
if (window.history && window.history.length === 1) {
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();

View File

@@ -9,8 +9,12 @@ export default class ColumnBackButtonSlim extends React.PureComponent {
};
handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
render () {

View File

@@ -1,13 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Glitch imports
import NotificationPurgeButtonsContainer from 'glitch/components/list/notif_cleaning_widget/container';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
});
@injectIntl
@@ -22,14 +27,19 @@ export default class ColumnHeader extends React.PureComponent {
title: PropTypes.node.isRequired,
icon: PropTypes.string.isRequired,
active: PropTypes.bool,
localSettings : ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
focusable: PropTypes.bool,
showBackButton: PropTypes.bool,
notifCleaning: PropTypes.bool, // true only for the notification column
notifCleaningActive: PropTypes.bool,
onEnterCleaningMode: PropTypes.func,
children: PropTypes.node,
pinned: PropTypes.bool,
onPin: PropTypes.func,
onMove: PropTypes.func,
onClick: PropTypes.func,
intl: PropTypes.object.isRequired,
};
static defaultProps = {
@@ -39,6 +49,7 @@ export default class ColumnHeader extends React.PureComponent {
state = {
collapsed: true,
animating: false,
animatingNCD: false,
};
handleToggleClick = (e) => {
@@ -59,17 +70,32 @@ export default class ColumnHeader extends React.PureComponent {
}
handleBackClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
handleTransitionEndNCD = () => {
this.setState({ animatingNCD: false });
}
onEnterCleaningMode = () => {
this.setState({ animatingNCD: true });
this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
}
render () {
const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props;
const { collapsed, animating } = this.state;
const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props;
const { collapsed, animating, animatingNCD } = this.state;
let title = this.props.title;
const wrapperClassName = classNames('column-header__wrapper', {
'active': active,
@@ -88,8 +114,20 @@ export default class ColumnHeader extends React.PureComponent {
'active': !collapsed,
});
const notifCleaningButtonClassName = classNames('column-header__button', {
'active': notifCleaningActive,
});
const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
'collapsed': !notifCleaningActive,
'animating': animatingNCD,
});
let extraContent, pinButton, moveButtons, backButton, collapseButton;
//*glitch
const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
@@ -138,13 +176,30 @@ export default class ColumnHeader extends React.PureComponent {
<h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title}
<div className='column-header__buttons'>
{backButton}
{ notifCleaning ? (
<button
aria-label={msgEnterNotifCleaning}
title={msgEnterNotifCleaning}
onClick={this.onEnterCleaningMode}
className={notifCleaningButtonClassName}
>
<i className='fa fa-eraser' />
</button>
) : null}
{collapseButton}
</div>
</h1>
{ notifCleaning ? (
<div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
<div className='column-header__collapsible-inner nopad-drawer'>
{(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
</div>
</div>
) : null}
<div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}

View File

@@ -19,6 +19,7 @@ export default class IconButton extends React.PureComponent {
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
flip: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
};
@@ -73,7 +74,7 @@ export default class IconButton extends React.PureComponent {
}
return (
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
<Motion defaultStyle={{ rotate: this.props.active ? (this.props.flip ? -180 : -360) : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? (this.props.flip ? -180 : -360) : 0, { stiffness: this.props.flip ? 60 : 120, damping: 7 }) : 0 }}>
{({ rotate }) =>
<button
aria-label={this.props.title}

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/gallery
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/action_bar
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/content
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container';
import StatusContainer from '../../glitch/components/status/container';
import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/player
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -23,7 +23,13 @@ const { localeData, messages } = getLocale();
addLocaleData(localeData);
export const store = configureStore();
const hydrateAction = hydrateStore(JSON.parse(document.getElementById('initial-state').textContent));
const initialState = JSON.parse(document.getElementById('initial-state').textContent);
try {
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
} catch (e) {
initialState.local_settings = {};
}
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
export default class Mastodon extends React.PureComponent {

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/container
import React from 'react';
import { connect } from 'react-redux';
import Status from '../components/status';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/account/header
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import InnerHeader from '../../account/components/header';
import InnerHeader from '../../../../glitch/components/account/header';
import ActionBar from '../../account/components/action_bar';
import MissingIndicator from '../../../components/missing_indicator';
import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { fetchAccount } from '../../actions/accounts';
import { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines';
import StatusList from '../../components/status_list';
import ListStatuses from 'glitch/components/list/statuses';
import LoadingIndicator from '../../components/loading_indicator';
import Column from '../ui/components/column';
import HeaderContainer from './containers/header_container';
@@ -64,7 +64,7 @@ export default class AccountTimeline extends ImmutablePureComponent {
<Column>
<ColumnBackButton />
<StatusList
<ListStatuses
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
scrollKey='account_timeline'
statusIds={statusIds}

View File

@@ -11,6 +11,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from './emoji_picker_dropdown';
import UploadFormContainer from '../containers/upload_form_container';
@@ -37,6 +38,9 @@ export default class ComposeForm extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
advanced_options: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool,
}),
spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
preselectDate: PropTypes.instanceOf(Date),
@@ -146,7 +150,8 @@ export default class ComposeForm extends ImmutablePureComponent {
render () {
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, countableText(this.props.text)].join('');
const maybeEye = this.props.advanced_options.get('do_not_federate') ? ' 👁️' : '';
const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
let publishText = '';
@@ -198,6 +203,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PrivacyDropdownContainer />
<ComposeAdvancedOptionsContainer />
<SensitiveButtonContainer />
<SpoilerButtonContainer />
</div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import StatusContainer from '../../../../glitch/components/status/container';
import Link from 'react-router-dom/Link';
import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@@ -15,6 +15,7 @@ const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
advanced_options: state.getIn(['compose', 'advanced_options']),
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),

View File

@@ -5,6 +5,8 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose';
import { openModal } from '../../actions/modal';
import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import Link from 'react-router-dom/Link';
import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container';
@@ -19,7 +21,7 @@ const messages = defineMessages({
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
});
@@ -48,6 +50,16 @@ export default class Compose extends React.PureComponent {
this.props.dispatch(unmountCompose());
}
onLayoutClick = (e) => {
const layout = e.currentTarget.getAttribute('data-mastodon-layout');
this.props.dispatch(changeLocalSetting(['layout'], layout));
e.preventDefault();
}
openSettings = () => {
this.props.dispatch(openModal('SETTINGS', {}));
}
onFocus = () => {
this.props.dispatch(changeComposing(true));
}
@@ -78,12 +90,14 @@ export default class Compose extends React.PureComponent {
{!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link>
)}
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><i role='img' className='fa fa-fw fa-cog' /></a>
<a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a>
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a>
</nav>
);
}
return (
<div className='drawer'>
{header}
@@ -104,6 +118,7 @@ export default class Compose extends React.PureComponent {
}
</Motion>
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import StatusList from '../../components/status_list';
import ListStatuses from 'glitch/components/list/statuses';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -77,7 +77,7 @@ export default class Favourites extends ImmutablePureComponent {
multiColumn={multiColumn}
/>
<StatusList
<ListStatuses
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`favourited_statuses-${columnId}`}

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