Compare commits

...

257 Commits

Author SHA1 Message Date
kibigo!
d7ce339c2e WIP <Compose> Refactor; Fin~ 2018-01-04 21:17:30 -08:00
kibigo!
42f50049ff WIP <Compose> Refactor; 1000 tiny edits 2018-01-04 18:33:13 -08:00
kibigo!
b4a3792201 WIP <Compose> Refactor; <ActionsModal>; dropdowns 2018-01-04 18:31:00 -08:00
kibigo!
083170bec7 WIP <Compose> Refactor; SCSS ed. 2018-01-04 18:23:46 -08:00
kibigo!
8713659dff WIP <Compose> Refactor; <OnboardingModal> ed. 2018-01-04 18:21:59 -08:00
kibigo!
3c29f57404 WIP <Compose> Refactor; <Drawer> ed. 2018-01-04 18:21:59 -08:00
kibigo!
924ffe81d4 WIPgit status <Compose> Refactor; <Composer> ed. 2018-01-04 18:21:59 -08:00
Jenkins
fc884d015a Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-03 20:17:13 +00:00
Quenty31
d907d4352e l10n OC language (#6169)
* new strings: hashtag+unlisted, mute, block

* Add confirmation step for email changes

* Add more instance stats APIs
2018-01-03 21:05:54 +01:00
ThibG
a8b51124ba Don't normalize URLs in toots (#6134)
* Don't normalize URLs in toots

URL normalization is ill-defined and may cause certain links to break.

* Change specs since we are not normalizing user-provided URLs
2018-01-03 20:51:33 +01:00
Akihiko Odaki
161c72d66d Allow to dereference Follow object for ActivityPub (#5772)
* Allow to dereference Follow object for ActivityPub

* Accept IRI as object representation for Accept activity
2018-01-03 18:08:57 +01:00
Marcin Mikołajczak
53d99ebf4f i18n: Update Polish translation (#6168)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2018-01-03 22:45:24 +09:00
Yamagishi Kazutoshi
1001922156 Add Japanese translations #5997, #6003, #6004, #6071, #6099, #6125 and #6132 (#6167)
* yarn manage:translations

* Add Japanese translation for #5997

* Add Japanese translation for #6003

* Add Japanese translation for #6004

* Add Japanese translation for #6071

* Add Japanese translation for #6099

* Add Japanese translation for #6125

* Add Japanese translation for #6132
2018-01-03 21:00:39 +09:00
Jenkins
933840bebf Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-03 04:17:11 +00:00
ThibG
99f962ba73 Allow HTTP caching of json view of public statuses (#6115)
* Allow HTTP caching of json view of public statuses

HTML views are not cached as they can contain private statuses as well

* Disable session cookies for ActivityPub json rendering of public toots
2018-01-03 04:57:57 +01:00
Akihiko Odaki
2471796d75 Set background to the navigation of Getting Started column (#6163)
The background of the navigation matters because its scrollbar is
transparent.
2018-01-03 04:39:14 +01:00
puckipedia
545095b3ce [!] Sanitize incoming classlist properly (#6162)
* Sanitize classlist properly

* Actually properly sanitize every class after the first

* Improve Formatter spec to check for multiple classes and non-space whitespace
2018-01-03 03:54:08 +01:00
Eugen Rochko
d319b3dbe4 Update moved-to property when it's removed too (#6160)
* Fix #6140 - Update moved-to property when it's removed too

* Remove trailing whitespace
2018-01-03 00:38:20 +01:00
Eugen Rochko
d60fd87e01 Don't leave behind husk of remotely-deleted profile (#6159)
There's no reason for an Account record to persist after Delete->Actor is received. SuspendAccountService is necessary to make sure deleted toots get sent over streaming API properly and home feeds get cleaned up. By removing Account record, we can ensure that if in the future the account is restored remotely (or username reused), it can start with a clean slate.
2018-01-03 00:38:02 +01:00
Noiob
94230fe565 Fix newlines-to-spaces functionality (#6158)
yay for regexes, amirite
2018-01-02 19:35:24 +01:00
Patrick Figel
04ecf44c2f Add confirmation step for email changes (#6071)
* Add confirmation step for email changes

This adds a confirmation step for email changes of existing users.
Like the initial account confirmation, a confirmation link is sent
to the new address.

Additionally, a notification is sent to the existing address when
the change is initiated. This message includes instruction to reset
the password immediately or to contact the instance admin if the
change was not initiated by the account owner.

Fixes #3871

* Add review fixes
2018-01-02 16:55:00 +01:00
ThibG
b6af88192f Display a warning when composing unlisted toots with something looking like a hashtag (#6132) 2018-01-02 14:24:52 +01:00
Eugen Rochko
1419f656e2 Fix stats expiring too quickly because of variable mistake (#6155) 2018-01-02 14:02:53 +01:00
Akihiko Odaki
3ba7cde38d Rename key to path in actions and reducers for settings (#6105) 2018-01-02 13:50:54 +01:00
Otakan
ce854ed506 delete X-UA-Compatible (#6068)
* delete X-UA-Compatible

* undo

* restore
2018-01-02 13:38:12 +01:00
Branko Kokanovic
21b9da6418 Adding Serbian latin translations (#6146)
Serbian latin (sr-Latn) is generated automatically from Serbian (sr) translation. Also changed some wording in original (Serbian) translation.
2018-01-02 20:39:12 +09:00
David Yip
fa768abf5c More ../ -> ~. 2018-01-02 00:26:44 -06:00
David Yip
54148b9a4a Merge remote-tracking branch 'origin/master' into merge-upstream
Conflicts:
	app/controllers/authorize_follows_controller.rb
	app/javascript/styles/mastodon/components.scss
2018-01-02 00:11:41 -06:00
Akihiko Odaki
764f876953 Use const instead of let for constant (#6106) 2018-01-02 13:28:49 +09:00
Akihiko Odaki
2c1ed5f872 Show mastodon on modal (#6129) 2018-01-02 05:07:56 +01:00
Jenkins
fa9b7ece2e Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-31 09:17:15 +00:00
Branko Kokanovic
7d376e41be Adding Serbian translation (#6133)
* Adding Serbian translation

* i18n-tasks normalize
2017-12-31 17:28:20 +09:00
David Yip
d817c0a958 Merge pull request #291 from glitch-soc/merge-upstream
Merge with upstream @ f4b80e6511
2017-12-30 18:24:38 -06:00
David Yip
4cca1d1e7e Merge remote-tracking branch 'origin/master' into merge-upstream
Conflicts:
	app/controllers/auth/confirmations_controller.rb
2017-12-30 17:20:07 -06:00
David Yip
e4944d065b Switch to tootsuite's elephant friend for the drawer
This version has a transparent background, which works better with the
wave.
2017-12-30 17:17:37 -06:00
David Yip
513d982f29 Use ~ notation in components for image references
This uses (more or less) absolute references to external assets, which
allows imported stylesheets (like components.scss) to work from other
locations that may not have the expected path structure (e.g. the win95
theme).
2017-12-30 17:15:20 -06:00
Jeong Arm
f4b80e6511 Translate Korean (#6131)
Relates to #6125, #6099
2017-12-30 02:44:19 +01:00
beatrix
a56c4742d3 keep the same filters and page when doing custom emojo stuff (fixes #6112) (#6114) 2017-12-30 02:43:43 +01:00
Eugen Rochko
38fc1b498d Add more instance stats APIs (#6125)
* Add GET /api/v1/instance/peers API to reveal known domains

* Add GET /api/v1/instance/activity API

* Make new APIs disableable, exclude private statuses from activity stats

* Fix code style issue

* Fix week timestamps
2017-12-29 19:52:04 +01:00
David Yip
65c87ca0ae Merge pull request #288 from chriswmartin/fix-gif-avatars
Fix GIF avatars not autoplaying when gif autoplay is enabled
2017-12-28 13:23:41 -06:00
MitarashiDango
511c6f9625 bug fix (WebPush does not work) (#6120) 2017-12-28 16:20:34 +01:00
cwm
832a93e67c Fix GIF avatars not autoplaying (fixes #287, tootsuite pr #6000) 2017-12-28 08:30:51 -06:00
ThibG
868568d1c1 Make host_meta/webfinger replies cacheable (fixes #6100) (#6101)
* Make host_meta/webfinger replies cacheable (fixes #6100)

Drop common code for handling users and sessions as webfinger queries
are very basic, public APIs.

Also explicitly mark results as cacheable with “expires_in”.

* Add “Vary: Accept” header for caching since content-negociation is used
2017-12-27 18:21:12 +01:00
David Yip
7174d1c955 Merge remote-tracking branch 'origin/master' into merge-upstream
Conflicts:
	app/javascript/images/mastodon-drawer.png
	app/javascript/styles/mastodon/components.scss
2017-12-26 22:24:19 -06:00
Akihiko Odaki
65f30f65a2 Move the mastodon on Getting Started column to drawer column (#6109)
Getting Started column obtained many links, and it became much taller.
Because of its height, Getting Started column required long scrolling on
devices with small screen, such as 4 inch phones and 10 inch laptops.

This change moves the mastodon which took large space on the column to
drawer column. The drawer column has only the compose form and has more
space.
2017-12-27 03:31:30 +01:00
Akihiko Odaki
e0ef7f9d79 Fix XML oEmbed support discovery (#6104) 2017-12-27 03:29:49 +01:00
beatrix
7347bc7334 Merge pull request #286 from chriswmartin/merge-vanilla-updates-into-glitch
Merge various small vanilla updates into glitch
2017-12-26 17:03:29 -05:00
beatrix
e6ab869d95 Merge pull request #285 from chriswmartin/detailed-status-actions
Add mute, block, conversation mute actions to detailed status dropdown
2017-12-26 17:03:00 -05:00
cwm
20ad071931 Set direction style to reply indicator (tootsuite pr #6006) 2017-12-26 14:20:41 -06:00
cwm
14243bf80a Reduce motion for boost animation (tootsuite pr #5871) 2017-12-26 14:04:52 -06:00
cwm
337c2e77ee Fix layout for RTL (tootsuite pr #6014, #6018) 2017-12-26 13:49:53 -06:00
cwm
46565ed746 Fix focused background color of favourited direct toot (tootsuite pr #6021) 2017-12-26 13:30:23 -06:00
cwm
815524412b Move dropdown transform origin (tootsuite pr #6091) 2017-12-26 13:28:19 -06:00
cwm
083571915f onMuteNotifications validation (tootsuite pr #6092) 2017-12-26 13:25:43 -06:00
cwm
2bbd22e91c Rename settingKey to settingPath (tootsuite pr #6046 & #6098) 2017-12-26 13:21:20 -06:00
Jenkins
6b85dba8da Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-26 18:17:16 +00:00
beatrix
127bfda521 add ruby-progressbar to gemfile (fixes #6110) (#6111) 2017-12-26 18:43:52 +01:00
takayamaki
1494509468 more faster index on notifications table (#6108) 2017-12-26 17:56:31 +01:00
cwm
cc6a2ffd0a use 'flavours/glitch/' prefix in new imports 2017-12-26 10:41:44 -06:00
cwm
22818d2a16 Add mute, block, conversation mute actions to detailed status dropdown menu 2017-12-26 10:13:38 -06:00
Jenkins
bed13f22e2 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-25 20:17:10 +00:00
Chris
1e5d1fa5c8 Add mute, block, conversation mute actions to detailed status dropdown menu (#6099)
* removed references to hideOnMobile in column_link and getting_started

* add mute, block, conversationMute actions to detailed status dropdown (fixes #1226)

* remove unused withDismiss in detailed status
2017-12-25 20:56:05 +01:00
MitarashiDango
a3b369337f Additional prop name change. (#6098) 2017-12-26 00:14:06 +09:00
Yamagishi Kazutoshi
43c37a4768 Add supported Node.js version to package.json (#6096) 2017-12-25 15:02:07 +01:00
Jenkins
f77c47d01b Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-24 15:17:12 +00:00
Eugen Rochko
cafe27fb29 Add rake task to check and purge accounts that are missing in origin (#6085)
* Add rake task to check and purge accounts that are missing in origin

* Add progress bar and --force options to mastodon:maintenance:purge_removed_accounts
2017-12-24 16:14:33 +01:00
Neetshin
7e6214b869 Add validation for onMuteNotifications (#6092)
* Add aria-autocomplete='list' in Textaria

ref: https://www.w3.org/TR/wai-aria-1.1/#aria-autocomplete

* Make detect empty string brefore assign upload description

* Change code elements in keyboard-shortcuts component to kbd

* Add validation for onMuteNotifications
2017-12-24 17:18:45 +09:00
Nolan Lawson
a8eb0bf44f Reduce motion for boost animation (#5871)
* Reduce motion for boost animation

Fixes #5833

* Fix ternary expression
2017-12-24 04:48:31 +01:00
Akihiko Odaki
35fdf561be Refactor web_push_subscription (#6047)
* Remove onSave method in mapped properties for column_settings

* Make web_push_subscription.register an action
2017-12-24 04:47:35 +01:00
Chris
081956742c removed references to hideOnMobile in column_link and getting_started (#6082)
* removed references to hideOnMobile in column_link and getting_started

* move keyboard shortcuts back below blocked users
2017-12-24 04:47:02 +01:00
cpsdqs
8528fd89d2 Move dropdown transform origin to top edge (#6091) 2017-12-24 00:53:03 +01:00
Jenkins
6d00ca1c71 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-22 04:17:14 +00:00
beatrix
4a7782d5c9 Merge pull request #280 from KnzkDev/add-en-ja
i18n: Add en and ja
2017-12-21 22:49:30 -05:00
ncls7615
fb57ec41df Update and add some translate 2017-12-22 12:22:41 +09:00
ncls7615
4694dcfe16 Add more en and ja 2017-12-22 12:11:28 +09:00
ncls7615
02db8aa3a0 Add en and ja 2017-12-22 12:01:38 +09:00
nightpool
9592b5e31e enforce LOCAL_HTTPS=true in production (#6061)
* enforce https in production

* note changes in production env sample

* typo fix
2017-12-22 02:17:59 +01:00
ThibG
cea98e0c12 Reduce the number of synchronous resolves when posting toots (#6075) 2017-12-22 02:15:08 +01:00
ThibG
6eb60260b1 Display deleted users' role as “Suspended” (#6080)
Deleted users are technically suspended, but the code displaying their status
in the admin interface was broken and displayed a javascript object holding
translations of the possible user roles instead.
2017-12-22 02:14:17 +01:00
beatrix
60d415bc5e Merge pull request #266 from chriswmartin/getting-started-improvements
Getting started column improvements
2017-12-21 18:47:07 -05:00
beatrix
f78d169d71 Merge pull request #278 from KnzkDev/fix/acc-header-avatar
Fix account header avatar
2017-12-21 17:46:53 -05:00
ncls7615
191b625a27 Fix account header avatar 2017-12-22 07:12:49 +09:00
beatrix
a3cb5a6b64 Merge pull request #277 from glitch-soc/273-add-data-status-by-to-detailed-status
Add data-status-by back to DetailedStatus component.  Fixes #273.
2017-12-21 16:10:36 -05:00
David Yip
7bb99ca3cf Add data-status-by back to DetailedStatus component. Fixes #273. 2017-12-21 13:22:33 -06:00
cwm
d736739e8f <kbd> instead of <code> in KeyboardShortcuts component (tootsuite pr #6049) 2017-12-21 13:04:40 -06:00
cwm
7917d45396 remove themes.default from i18n-tasks unused check 2017-12-21 12:45:11 -06:00
beatrix
bc0152974f Merge pull request #276 from glitch-soc/245-click-avatar-to-open-profile-in-new-tab
Wrap <Avatar> in account header in a link to that account's page (fixes #245)
2017-12-21 08:30:22 -05:00
beatrix
4d34b3495d Merge pull request #274 from glitch-soc/fix-packs-on-2fa-pages
Set packs on 2FA-related pages.  Fixes #271.
2017-12-21 08:30:05 -05:00
David Yip
dbd5f8b9a5 Wrap <Avatar> in account header in a link to that account's page. Fixes #245. 2017-12-21 05:25:16 -06:00
David Yip
00d1f8eea1 Remove themes.default from i18n-tasks unused check
This translation key comes in from tootsuite, but the flavours system
does not use it.
2017-12-21 05:24:17 -06:00
David Yip
a1ddc89da6 Remove unused themes.default key in ko locale. 2017-12-20 20:02:50 -06:00
cwm
9042f9a813 add keyboard shortcuts to getting started (fixes #275) 2017-12-20 09:50:29 -06:00
David Yip
bf1eb0912c Set packs on 2FA-related pages. Fixes #271.
Specifically, this commit:

- changes S::TFA::{Confirmations,RecoveryCodes}Controller to derive from
  S::BaseController, because this gives us the necessary actions and
  packs
- prepends set_pack to Auth::SessionsController's action chain so that
  it takes effect in time for render :two_factor
2017-12-20 03:15:54 -06:00
Jenkins
6f11aa8383 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-20 08:17:15 +00:00
Akihiko Odaki
81d29e4126 Rename settingKey in setting_toggle to settingPath (#6046) 2017-12-20 16:19:59 +09:00
Neetshin
c11a52d888 Replace <code> to <kbd> in KeyboardShortcuts component (#6049)
* Add aria-autocomplete='list' in Textaria

ref: https://www.w3.org/TR/wai-aria-1.1/#aria-autocomplete

* Make detect empty string brefore assign upload description

* Change code elements in keyboard-shortcuts component to kbd
2017-12-20 11:46:25 +09:00
Jeong Arm
e52293482e Update Korean translation (#6050)
* Update Korean translation

* Translate Korean for javascript

* Add missing translations on simple_form
2017-12-20 11:45:50 +09:00
beatrix
6cb47f17fc Merge pull request #272 from KnzkDev/add-en-ja
i18n: Update some locale files
2017-12-19 10:35:02 -05:00
ncls7615
d3b2677775 Add en and ja 2017-12-19 20:16:48 +09:00
cwm
14aae9c05c missed a semicolon 2017-12-17 22:21:15 -06:00
cwm
be50e45a74 use makeMapStateToProps 2017-12-17 22:00:25 -06:00
cwm
e4ebbf4f07 use list-ul icon in list header and web/lists 2017-12-17 21:25:18 -06:00
Jenkins
e7fa7fce25 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-17 03:17:14 +00:00
Peter
f38e6a14f2 Add Slovak translation (#6052)
* Add Slovak translation

* Slovak translation: i18n-normalize
2017-12-17 11:26:42 +09:00
Jenkins
05a2a70ca2 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-16 15:17:12 +00:00
beatrix
e202efdf8a Merge pull request #253 from glitch-soc/prevent-local-only-federation
prevent federation of local-only statuses
2017-12-16 09:26:48 -05:00
beatrix
5325c2ab3e Merge pull request #270 from KnzkDev/fix/action_logs
Fix admin/action_logs page
2017-12-16 09:25:57 -05:00
ncls7615
9e8fa3caee fix admin/action_logs page 2017-12-16 20:27:26 +09:00
kibigo!
e677dda07c Fixes #244 to make search results scrollable 2017-12-15 12:49:23 -08:00
Daigo 3 Dango
a434d9c0cc Remove period from the version number (#6039)
2.1.0. -> 2.1.0
2017-12-15 21:38:25 +01:00
David Yip
82b2e224a2 Merge branch 'gs-master' into prevent-local-only-federation
Conflicts:
	db/schema.rb
2017-12-15 12:20:56 -06:00
Jenkins
40afadfb01 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-15 18:17:14 +00:00
Eugen Rochko
a29432f0cd Bump version to 2.1.0 🎆 2017-12-15 19:14:57 +01:00
beatrix
e0a573a2ce Merge pull request #269 from ncls7615/patch-1
Update ja.js
2017-12-15 09:12:54 -05:00
NCLS
e9a5bde9a0 Update ja.js 2017-12-15 19:43:10 +09:00
NCLS
61ed5b972c Update ja.js 2017-12-15 19:38:13 +09:00
Jenkins
37254c4f5d Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-15 01:17:12 +00:00
Eugen Rochko
098c7d27fe Bump version to 2.1.0rc6 2017-12-15 02:00:28 +01:00
Eugen Rochko
3d3b403359 Do not hide statuses from silenced accounts from other silenced accounts (#6030) 2017-12-15 01:54:05 +01:00
Naoki Kosaka
25b0d7538e Fix oEmbed image_modal src. (#6027) 2017-12-14 23:31:14 +01:00
Eugen Rochko
a3b2ea599d Fix #6022 - Prevent nested migrated accounts, or migrations to self (#6026) 2017-12-14 21:35:30 +01:00
SerCom_KC
573414f728 Improve Chinese (Simplified) Translations (#6024)
* i18n: (zh-CN) Change `工作人员` (staff) to `管理人员`
Suggested by @Gargron at https://github.com/tootsuite/mastodon/pull/6005#discussion_r156678109

* i18n: (zh-CN) Change `协管` to `监察员`

* i18n: (zh-CN) Fix all "Are you" questions

* i18n: (zh-CN) Various improvements

* i18n: (zh-CN) Final clean-up

* i18n: (zh-CN) Change translation for 500

* i18n: (zh-CN) Remove spaces between time distances

* i18n: (zh-CN) Improve translations
2017-12-14 19:33:29 +01:00
Jeroen
aa273a2718 Last minute Dutch string updates (#6025)
* Last minute Dutch strinfupdate

* Last minute Dutch strings update

* Fixing Weblate output errors

* Fixing Weblate output errors

* Fixing more Weblate rubish

Weblate is also changing some " to ' - I think that is not a problem

* Fixing more weblate stuff

* Fixing

* Update nl.yml
2017-12-14 18:45:32 +01:00
David Yip
6abb0950c6 Examples for Status.as_public_timeline.
Also adjust the examples for Status.as_tag_timeline to match the
nomenclature used in .as_public_timeline (e.g. "account" -> "viewer").
2017-12-14 02:57:59 -06:00
David Yip
e35a350119 Examples for Status#set_locality and .as_tag_timeline.
This commit also:

- exposes the local-only emoji so that it can be used in examples
- allows local_only to be set explicitly, i.e. for timeline filtering
  specs
2017-12-14 02:27:42 -06:00
Jenkins
e5a9831a56 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-13 23:17:15 +00:00
Lynx Kotoura
0d3ffa691e Fix focused background color of notifications of direct toots (#6021) 2017-12-14 07:36:29 +09:00
Lynx Kotoura
5ad45552b3 Fix overflowing emojis on some devices (#6016)
* Fix overflowing emojis on some devices

* Quit visible and add padding
2017-12-13 22:58:31 +01:00
Olivier Humbert
dc313f27bb 1 fix + 1 translation (#6019) 2017-12-13 22:58:20 +01:00
Jenkins
10f800ab46 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-13 20:17:13 +00:00
Eugen Rochko
7cad926401 Bump version to 2.1.0rc5 2017-12-13 20:53:39 +01:00
Eugen Rochko
3487460f00 Fix regression from #6014 (#6018) 2017-12-13 20:33:04 +01:00
Jenkins
82236a3703 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-12-13 18:17:16 +00:00
Quenty31
72314d26ae l10n OC and FR updates (#6017)
* Adjust empty list timeline message (#5997)

* Adjust empty list timeline message (#5997)

* Add filters to admin UI for custom emojis (#6003) + #6004

* Update fr.yml
2017-12-14 03:17:04 +09:00
Eugen Rochko
cc75d47926 Fix layout for RTL (#6014) 2017-12-13 18:28:13 +01:00
Lynx Kotoura
8bf4cc72b6 Excahnge the order of spoiler-input and unlocked warning (#6015)
* Excahnge the order of spoiler-input and unlocked warning

* Fix trailing whitespace
2017-12-13 18:01:56 +01:00
Olivier Humbert
ad941f5a21 Update FR translation (#6012) 2017-12-13 18:00:42 +01:00
Lynx Kotoura
0aeec0390b Redesign tootbox (#5919)
* Redesign tootbox

* Move counter into compose-form__buttons-wrapper

Change font and remove shadow
Refactor sass codes of compose-form
2017-12-13 17:37:23 +01:00
Eugen Rochko
fef6625496 Weblate translations (#6011)
* Translated using Weblate (Dutch)

Currently translated at 100.0% (522 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (French)

Currently translated at 99.8% (521 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ca/

* Translated using Weblate (Catalan)

Currently translated at 99.4% (519 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ca/

* Translated using Weblate (Japanese)

Currently translated at 99.4% (519 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ja/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/nl/

* Translated using Weblate (Galician)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/gl/

* Translated using Weblate (Japanese)

Currently translated at 99.6% (520 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ja/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ja/

* Translated using Weblate (Arabic)

Currently translated at 40.0% (209 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ar/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ar/

* Translated using Weblate (Polish)

Currently translated at 99.8% (521 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pl/

* Added translation using Weblate (Galician)

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.0% (517 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Galician)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/gl/

* Added translation using Weblate (Galician)

* Translated using Weblate (Galician)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/gl/

* Translated using Weblate (Galician)

Currently translated at 22.6% (17 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/gl/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt/

* Translated using Weblate (Portuguese)

Currently translated at 66.0% (37 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt/

* Translated using Weblate (Japanese)

Currently translated at 99.6% (520 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ja/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ja/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (520 of 522 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ar/

* Translated using Weblate (Arabic)

Currently translated at 48.8% (21 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/ar/

* Translated using Weblate (Arabic)

Currently translated at 98.2% (55 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ar/

* Translated using Weblate (Portuguese)

Currently translated at 73.2% (41 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/pt/

* i18n-tasks normalize && yarn manage:translations

* Restore wrongfully deleted files
2017-12-13 16:37:15 +01:00
SerCom_KC
775c3056b6 Update Chinese (Simplified) translations (#6005)
* i18n: (zh-CN) Remove spaces in time distances

* i18n: (zh-CN) Update translations for #5997

* i18n: (zh-CN) Add translation for #6004
also change translation of `staff` from `管理员` to `工作人员`

* i18n: (zh-CN) Add translations for #6003

* i18n: (zh-CN) Normalization
2017-12-13 15:52:40 +01:00
nullkal
ccf4f170de Make sure call done(); in the listener of public timeline for anonymous connection (#6009) 2017-12-13 14:27:36 +01:00
nullkal
90e7da16a0 Fix the condition in streaming listener (#6008) 2017-12-13 13:42:16 +01:00
Eugen Rochko
ad75ec8b5b Add filters to admin UI for custom emojis (#6003) 2017-12-13 13:28:31 +01:00
Eugen Rochko
57fcc21a86 Bump version to 2.1.0rc4 2017-12-13 12:45:12 +01:00
Yamagishi Kazutoshi
6855baa0c5 Change streaming API URL when remote development (#5942)
* Change streaming API URL when remote development

* Use STREAMING_API_BASE_URL when dev env
2017-12-13 12:43:54 +01:00
Yamagishi Kazutoshi
07b4427865 Set direction style to reply indicator (#6006) 2017-12-13 12:17:37 +01:00
Eugen Rochko
a8deb6648b Fix redundant HTTP request in FetchLinkCardService (#6002) 2017-12-13 12:15:28 +01:00
Eugen Rochko
20a6584d2d Clean up admin UI for accounts (#6004)
* Add staff filter to admin UI for accounts, remove obsolete columns

* Only display OStatus section in admin UI for accounts when OStatus data
2017-12-13 12:15:10 +01:00
Eugen Rochko
155e211dd0 Fix GIF avatars not autoplaying when GIF autoplay is enabled (#6000) 2017-12-13 12:14:03 +01:00
Eugen Rochko
81923f88ba Shorten English title for 2FA to avoid line-break (#6001) 2017-12-13 15:42:22 +09:00
cwm
17e53b931c code style fixes 2017-12-12 23:07:12 -06:00
cwm
9719983f2d move list items into an array, don't unnecessarily concat misc nav item 2017-12-12 22:50:20 -06:00
Eugen Rochko
5706fe18c2 Fix #5952 - NameError (regression from #5762) (#5999)
* Fix #5952 - NameError (regression from #5762)

* Fix
2017-12-13 04:12:38 +01:00
Eugen Rochko
71965cbef2 Adjust empty list timeline message (#5997) 2017-12-13 02:40:32 +01:00
Eugen Rochko
0128b86d30 Use streaming API for standalone timelines on /about and /tag pages (#5998) 2017-12-13 02:12:41 +01:00
Quenty31
0370ba7b0a Update: #5985 and #5817 (#5996) 2017-12-12 20:48:26 +01:00
erin
c986218c3a Improve error handling in streaming/index.js (#5968)
On an unhandled worker exception, we should log the exception
and exit with nonzero status, instead of letting workers
silently fail and restarting them in an endless loop.

Note: we previously tried to handle the `'error'` signal.
That's not a signal Node fires; my patch traps `'uncaughtException'`,
which is what the code was _trying_ to do.
2017-12-12 20:19:33 +01:00
Neetshin
0c8b1eb577 Make detect empty string before assign image description (#5994)
* Add aria-autocomplete='list' in Textaria

ref: https://www.w3.org/TR/wai-aria-1.1/#aria-autocomplete

* Make detect empty string brefore assign upload description
2017-12-12 19:57:22 +01:00
abcang
cfa3f55221 Remove duplicate indexes in lists (#5990) 2017-12-12 17:38:42 +01:00
Akihiko Odaki
f9f6918148 Store preview image for embedded photo in preview cards (#5986)
The preview image would be useful to embed in timeline.
2017-12-12 15:54:38 +01:00
cwm
b6ded7119e travis fixes 2017-12-12 08:50:31 -06:00
beatrix
65d083338d Merge pull request #263 from KnzkDev/optimize-paperclip
Optimizing paperclip
2017-12-12 09:15:52 -05:00
SerCom_KC
2a61b9f000 Update Chinese (Simplified) translations (#5991)
* i18n: (zh-CN) Update translations for #5817

* i18n: (zh-CN) Add translation for #5985

* i18n: (zh-CN) Normalization
2017-12-12 15:13:47 +01:00
nullkal
cfea28216f make it possible to stream public timelines without authorization (#5977)
* make it possible to stream public timelines without authorization

* Fix

* Make eslint allow `value == null`

* Remove redundant line

* Improve style and revert .eslintrc.yml

* Fix streamWsEnd

* Show IP address instead of (anonymous user)

* Add missing semicolon
2017-12-12 15:13:24 +01:00
Renato "Lond" Cerqueira
19257d91bf Return false if object does not respond to url (#5988)
Avoid error when the service returns a mostly valid oembed, but has no
url in it, causing a MethodError: undefined method `url'
for #<OEmbed::Response::Photo:0x000056505def9620>
2017-12-12 15:12:09 +01:00
Renato "Lond" Cerqueira
fe180f18ff Change conditional to avoid nil into string error in sidekiq (#5987)
* Change conditional to avoid nil into string error in sidekiq
When obtaining information about users with mastodon in a different
subdomain, sidekiq was giving out a 'no implicit conversion of nil into String'

* Use presence instead of blank? with ternary.
Following suggestion on PR
2017-12-12 15:11:13 +01:00
Yamagishi Kazutoshi
1486fd64cc Move files for GitHub to .github directory (#5989) 2017-12-12 15:10:12 +01:00
beatrix
504c3d650f Merge pull request #264 from KnzkDev/add-ja
i18n: Add ja translate
2017-12-12 08:49:19 -05:00
David Yip
be33247235 Remove themes.default from nl and pt-BR locales.
This translation has been supplanted by the flavours-related
translations.
2017-12-12 03:28:15 -06:00
David Yip
a057ed5cfe Merge remote-tracking branch 'tootsuite/master' into merge-upstream 2017-12-12 02:54:13 -06:00
cwm
9b7208f4b4 readded connect for onboarding modal 2017-12-12 00:17:07 -06:00
cwm
b358483ef2 remove unneeded imports from getting_started_misc 2017-12-12 00:09:18 -06:00
cwm
37ced4c903 add components to getting started column 2017-12-12 00:01:17 -06:00
ncls7615
b93ad3d0e8 add ja 2017-12-12 13:13:54 +09:00
ncls7615
c0c4526283 add ja for fav conf 2017-12-12 13:05:48 +09:00
Gô Shoemake
04eaa1943f Merge pull request #262 from chriswmartin/fix-oauth-pack
Add missing set pack to authorizations_controller
2017-12-11 19:34:19 -08:00
ncls7615
c95af71da5 optimize paperclip 2017-12-12 12:29:09 +09:00
Akihiko Odaki
14c4a33cd9 Change account_id non-nullable in lists (#5979) 2017-12-12 04:11:17 +01:00
Eugen Rochko
30d2ea03b0 Improve public status page title (#5985) 2017-12-12 03:56:30 +01:00
Eugen Rochko
1356ed72cd Fix #5953 - Add GET /api/v1/accounts/:id/lists (#5983) 2017-12-12 03:55:39 +01:00
Eugen Rochko
481fac7c84 Exclude moved accounts from search results (#5984) 2017-12-12 02:14:33 +01:00
cwm
0a52e37648 change pack to 'auth' 2017-12-11 18:14:41 -06:00
cwm
44992df257 load pack 2017-12-11 17:54:40 -06:00
Erin
2efffe77dc reblog_service.rb: Status#local_only -> local_only? 2017-12-11 17:39:11 -06:00
Erin
c5a4eda694 move outbox filtering to Status#permitted_for (as per @ekiru) 2017-12-11 15:28:04 -06:00
Erin
3ec47e732b post_status_service: stylistic change (local_only -> local_only?) 2017-12-11 15:27:31 -06:00
Quenty31
c588fcf4bc Tiny little change (#5981) 2017-12-11 20:53:29 +01:00
kibigo!
8aa527434c Fixed index in webpack config 2017-12-11 10:45:21 -08:00
beatrix
7d024a6b68 Merge pull request #259 from KnzkDev/flavours-ja
i18n: Add ja translate for flavour/skin ux
2017-12-11 10:56:41 -05:00
ncls7615
d8206d1931 add ja 2017-12-12 00:28:33 +09:00
beatrix
771b950feb Merge pull request #254 from glitch-soc/new-theme-ux
New flavour/skin UX
2017-12-11 09:36:14 -05:00
Eugen Rochko
feed07227b Apply a 25x rate limit by IP even to authenticated requests (#5948) 2017-12-11 15:32:29 +01:00
beatrix
0cd5f2a61f Merge pull request #255 from KnzkDev/remove-picture
Remove getting-started picture
2017-12-11 09:08:13 -05:00
Akihiko Odaki
e56323a4dd Remove preview_card fabricator (#5975)
preview_card fabricator has a removed attribute, status, and is no longer
functional.
2017-12-11 22:22:08 +09:00
David Yip
204688e803 Add missing set_pack def/filter in OAuth::AuthorizedApplicationsController. 2017-12-11 00:17:30 -06:00
ncls7615
9d5ecdbf41 remove picture 2017-12-11 13:52:17 +09:00
David Yip
1138d0c321 Add Rake task to backfill local-only flag (#253) 2017-12-10 22:49:59 -06:00
kibigo!
ed7231947c Added styling 2017-12-10 20:32:28 -08:00
kibigo!
bdca1614d5 Screenshot support for themes 2017-12-10 20:32:28 -08:00
kibigo!
dabf66e676 Moved flavour UI into own prefs tab 2017-12-10 20:32:27 -08:00
beatrix
08b0861b96 Merge pull request #250 from chriswmartin/fav-confirm-modal
add favourite confirmation modal
2017-12-10 23:10:19 -05:00
beatrix
01a3461bef Merge pull request #252 from ncls7615/glitch-langfiles
i18n: Add translate and fix problems
2017-12-10 22:53:48 -05:00
Erin
288f1293ef set local_only in a before_create callback instead of status service 2017-12-10 21:39:27 -06:00
Erin
0c46058a43 remove vestigial Status#local_only? definition 2017-12-10 21:25:36 -06:00
Erin
c1410af368 post_status_service.rb: save the status after setting local_only 2017-12-10 20:35:57 -06:00
Erin
24f36ca912 Status#not_local_only scope should match nils too 2017-12-10 20:35:42 -06:00
Sylvhem
84d5bfb35e Change the disclaimer under the sign up form (#5817)
* Change the disclaimer below the sign up form

Change the disclaimer below the sign up form on the home page. The current text is linking to the /about/more page under "our terms of service" and to the /terms page under "privacy policy". This change intend to make the message more coherent.

Change l’avertissement en-dessous du formulaire d’inscription sur la page d’accueil. Le texte actuel redirige vers /about/more sous un lien intitulé "nos conditions d’utilisation" et vers /terms via "notre politique de confidentialité". Ce changement vise à rendre le message plus cohérent.

* Second take on the disclaimer

A new version of the disclaimer, based on feedback.

Une nouvelle version de l’avertissement, basé sur les premiers retours.
2017-12-11 02:30:43 +01:00
Erin
f080a9fac7 filter local-only toots from AP outboxes 2017-12-10 19:07:43 -06:00
ncls7615
d420e2f047 add comma 2017-12-11 09:50:52 +09:00
ncls7615
279231c5dd " => ' 2017-12-11 09:46:17 +09:00
ncls7615
a98b0a47ef Merge branch 'master' of https://github.com/glitch-soc/mastodon into glitch-langfiles
# Conflicts:
#	app/javascript/glitch/locales/ja.json
2017-12-11 09:43:21 +09:00
Erin
6bd18e43ba filter local-only statuses from public pages 2017-12-10 17:23:01 -06:00
Erin
5ef65aab8f replace reblog service check for an 👁️ with #local_only 2017-12-10 17:12:21 -06:00
Erin
cfbb95605b set local_only flag on statuses in post_status_service 2017-12-10 17:04:32 -06:00
Erin
08519cd4f4 status: stub local_only?, add scope, add marked_local_only? 2017-12-10 17:04:28 -06:00
Erin
434c70fd98 add a local_only column to the statuses table 2017-12-10 16:41:25 -06:00
cwm
0466aa8d08 use single quotes in locale entry 2017-12-10 15:39:23 -06:00
cwm
072ab191cc pulled master, moved locale entry to new location 2017-12-10 15:22:15 -06:00
cwm
eec5d350fd removed unneeded actions_modal div 2017-12-10 15:14:56 -06:00
beatrix
26c9b9fa27 Merge pull request #246 from glitch-soc/theme-intl8n
Internationalization for flavours and skins
2017-12-10 15:41:22 -05:00
Andrea Scarpino
6a82939adb Fix account and tag searches with leading/trailing spaces (#5965)
* Strip leading & trailing spaces from account query

* Strip leading & trailing spaces from tag search
2017-12-10 19:35:46 +01:00
Lynx Kotoura
98aa96b8d6 Refix extraspace for emojis (#5964)
Fix misalignment between emoji sizes
2017-12-10 17:56:05 +01:00
abcang
3caec1ecc2 Save media outside transaction (#5959) 2017-12-10 16:33:52 +01:00
cwm
066458a659 removed one last app settings addition 2017-12-10 09:25:44 -06:00
goofy-bz
2950de86c6 Update devise.fr.yml (#5963)
ludicrously tiny but necessary typofix (wrong accent)
2017-12-11 00:24:29 +09:00
cwm
7a8711ccac removed app settings additions 2017-12-10 09:10:47 -06:00
Quenty31
7d4ebeecbd l10n i18n OC: corrections (#5962)
* filling missing strings

* Small changes

Better way of saying
+ removed 2 finals dots

* Corrections

* Corrections

Now with final point or without, just like the EN file

* Update oc.json
2017-12-11 00:07:24 +09:00
ncls7615
0e56797792 add and fix 2017-12-10 17:33:22 +09:00
Yamagishi Kazutoshi
6e3f176b8e Add Galician language support (#5955) 2017-12-10 04:19:07 +01:00
Olivier Humbert
a4710f9af8 French translation update (#5954)
* Update French translation

* fix
2017-12-10 09:47:59 +09:00
abcang
fcc0795a40 Remove unused function (#5950) 2017-12-09 23:37:31 +01:00
ButterflyOfFire
0f8140d26a Create activerecord.ar.yml (#5951) 2017-12-09 23:37:18 +01:00
cwm
8606e53384 moved locales to glitch, created add settings entry 2017-12-09 15:15:11 -06:00
cwm
fbd2a0127c ran i18n-tasks normalize for simple_form.en.yml 2017-12-09 13:00:07 -06:00
cwm
c5a688d70e remove trailing spaces 2017-12-09 12:41:24 -06:00
cwm
7284e36fbd fixed fav setting change 2017-12-09 12:17:20 -06:00
cwm
22cdbca82c fixes, functioning now 2017-12-09 12:06:00 -06:00
cwm
a489e5d5cd added a few more things 2017-12-09 11:21:41 -06:00
cwm
abe95b614b add initial components based off of tootsuite pr #1507 2017-12-09 10:26:22 -06:00
Yamagishi Kazutoshi
e7d55df38d Ignore HEAD method if does not support (#5949) 2017-12-09 16:53:40 +01:00
Eugen Rochko
a72d03f43c Weblate translations (#5946)
* Translated using Weblate (German)

Currently translated at 84.2% (439 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (English)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/en/

* Translated using Weblate (German)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/de/

* Translated using Weblate (English)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/en/

* Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/de/

* Translated using Weblate (French)

Currently translated at 84.6% (441 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (German)

Currently translated at 86.9% (453 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (Korean)

Currently translated at 86.3% (450 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ko/

* Translated using Weblate (French)

Currently translated at 84.8% (442 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (French)

Currently translated at 84.8% (442 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (French)

Currently translated at 84.8% (442 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Portuguese)

Currently translated at 36.2% (189 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/he/

* Translated using Weblate (Hebrew)

Currently translated at 53.1% (277 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/he/

* Translated using Weblate (Spanish)

Currently translated at 75.6% (394 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/es/

* Translated using Weblate (French)

Currently translated at 86.3% (450 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Portuguese)

Currently translated at 98.2% (55 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/nl/

* Translated using Weblate (Dutch)

Currently translated at 84.6% (441 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/de/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ca/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/nl/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/ca/

* Translated using Weblate (German)

Currently translated at 88.2% (460 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/nl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.2% (470 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (French)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.2% (470 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (French)

Currently translated at 87.3% (455 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Hebrew)

Currently translated at 61.8% (322 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/he/

* Translated using Weblate (French)

Currently translated at 87.3% (455 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.3% (481 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ca/

* Translated using Weblate (French)

Currently translated at 87.3% (455 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (French)

Currently translated at 87.5% (456 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (French)

Currently translated at 87.7% (457 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/ca/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ca/

* Translated using Weblate (Portuguese)

Currently translated at 42.4% (221 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt/

* Translated using Weblate (Portuguese)

Currently translated at 97.3% (73 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/pt/

* Translated using Weblate (Catalan)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ca/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2 of 2 strings)

Translation: Mastodon/Activerecord
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/activerecord/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/nl/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt/

* Translated using Weblate (German)

Currently translated at 90.5% (472 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (German)

Currently translated at 90.7% (473 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (German)

Currently translated at 90.9% (474 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (German)

Currently translated at 91.1% (475 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (Dutch)

Currently translated at 90.4% (471 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/de/

* Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.3% (481 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2 of 2 strings)

Translation: Mastodon/Activerecord
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/activerecord/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/pt_BR/

* Translated using Weblate (Dutch)

Currently translated at 90.5% (472 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Arabic)

Currently translated at 44.1% (19 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/ar/

* Translated using Weblate (Norwegian (old code))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/no/

* Translated using Weblate (Arabic)

Currently translated at 85.7% (48 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ar/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ar/

* Translated using Weblate (Arabic)

Currently translated at 92.0% (69 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/ar/

* Translated using Weblate (Portuguese)

Currently translated at 47.7% (249 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt/

* Translated using Weblate (Arabic)

Currently translated at 29.9% (156 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ar/

* Translated using Weblate (Spanish)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/es/

* Translated using Weblate (Polish)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pl/

* Translated using Weblate (French)

Currently translated at 99.6% (519 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/es/

* Translated using Weblate (French)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/fr/

* Translated using Weblate (Spanish)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/es/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.2% (517 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Added translation using Weblate (Galician)

* Translated using Weblate (Japanese)

Currently translated at 99.6% (519 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/pt_BR/

* Translated using Weblate (Japanese)

Currently translated at 92.8% (52 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ja/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.4% (518 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ja/

* Translated using Weblate (Galician)

Currently translated at 43.6% (107 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/gl/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/ja/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/ar/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.4% (518 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/

* Translated using Weblate (Arabic)

Currently translated at 96.4% (54 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ar/

* Translated using Weblate (Arabic)

Currently translated at 31.2% (163 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ar/

* Translated using Weblate (Dutch)

Currently translated at 91.5% (477 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (French)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/nl/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/ar/

* Translated using Weblate (Arabic)

Currently translated at 98.2% (55 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/ar/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (521 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (521 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/

* Translated using Weblate (French)

Currently translated at 99.8% (520 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/

* Translated using Weblate (Norwegian (old code))

Currently translated at 50.6% (264 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/no/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/nl/

* Translated using Weblate (Galician)

Currently translated at 64.0% (157 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/gl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2 of 2 strings)

Translation: Mastodon/Activerecord
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/activerecord/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (75 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/nl/

* Translated using Weblate (Norwegian (old code))

Currently translated at 100.0% (43 of 43 strings)

Translation: Mastodon/Devise
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/devise/no/

* Translated using Weblate (Norwegian (old code))

Currently translated at 97.3% (73 of 75 strings)

Translation: Mastodon/Doorkeeper
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/doorkeeper/no/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (56 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/nl/

* Translated using Weblate (Norwegian (old code))

Currently translated at 96.4% (54 of 56 strings)

Translation: Mastodon/Preferences
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/no/

* Translated using Weblate (Galician)

Currently translated at 100.0% (245 of 245 strings)

Translation: Mastodon/React
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/gl/

* Translated using Weblate (German)

Currently translated at 95.2% (496 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* Translated using Weblate (German)

Currently translated at 95.2% (496 of 521 strings)

Translation: Mastodon/Backend
Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/de/

* i18n-tasks normalize && yarn manage:translations

* Fix things
2017-12-09 15:35:22 +01:00
Naoki Kosaka
4bce376fdc Missing require 'authorization_decorator'. (#5947) 2017-12-09 15:12:10 +01:00
Eugen Rochko
a865b62efc Rate limit by user instead of IP when API user is authenticated (#5923)
* Fix #668 - Rate limit by user instead of IP when API user is authenticated

* Fix code style issue

* Use request decorator provided by Doorkeeper
2017-12-09 14:20:02 +01:00
SerCom_KC
84cebad49d Update Chinese (Simplified) translations (#5937)
* i18n: (zh-CN) Improve translations for email notifications

* i18n: (zh-CN) Improve translations

* i18n: (zh-CN) Fix subject

* i18n: (zh-CN) Update translations for #5933
2017-12-09 14:19:45 +01:00
Yamagishi Kazutoshi
931e66e572 Back to Web UI from tag page when signed in (#5943) 2017-12-09 14:19:07 +01:00
Yamagishi Kazutoshi
cdae7e4c8b Move push notifications settings (regression from #5879) (#5941)
* Move push notifications settings

* fix typo `setf` -> `set`
2017-12-09 14:18:45 +01:00
Quenty31
3a52c90de1 l10n i18n OC update (#5939)
* update and corrections

* update (invites)

* Update oc.yml

* Update oc.yml
2017-12-09 14:17:34 +01:00
THE BOSS ♨
17e26f8afe Fix typo in paperclip.rb (#5936) 2017-12-09 13:59:59 +09:00
Eugen Rochko
2526ef10c2 Bump version to 2.1.0rc3 2017-12-09 02:42:59 +01:00
abcang
99242b92bc Keep WebPush settings (#5879) 2017-12-09 02:31:37 +01:00
Eugen Rochko
ec3b449baa Fix #5630 - Prevent duplicate load of favourites (#5931) 2017-12-09 02:22:13 +01:00
Eugen Rochko
2f4c5f504f Limit users to 50 lists, remove pagination from lists API (#5933) 2017-12-09 01:32:29 +01:00
Yamagishi Kazutoshi
f08e6e9ab5 Audio.prototype.seek is undefined (#5935) 2017-12-09 01:25:00 +01:00
Eugen Rochko
86b4d5439c Fix #5926 - Do not downgrade to OStatus once ActivityPub is known (#5929) 2017-12-09 01:24:47 +01:00
Eugen Rochko
c36b9cc5a6 Ensure link thumbnails are not stretched to super low quality (#5932) 2017-12-09 00:56:16 +01:00
Eugen Rochko
70ce2a2095 Polish video player CSS, add timer on fullscreen/modal/public pages (#5928) 2017-12-09 00:55:58 +01:00
Yamagishi Kazutoshi
b0db4dad79 Revert fog-aws (ref #5604) (#5934) 2017-12-09 00:47:52 +01:00
398 changed files with 11860 additions and 4840 deletions

View File

@@ -11,10 +11,11 @@ DB_PASS=
DB_PORT=5432
# Federation
# Note: Changing LOCAL_DOMAIN or LOCAL_HTTPS at a later time will cause unwanted side effects.
# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation.
# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com.
LOCAL_DOMAIN=example.com
LOCAL_HTTPS=true
# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links)
# Use this only if you need to run mastodon on a different domain than the one used for federation.
# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md

View File

View File

@@ -14,12 +14,13 @@ gem 'pg', '~> 0.20'
gem 'pghero', '~> 1.7'
gem 'dotenv-rails', '~> 2.2'
gem 'fog-aws', '~> 1.4', require: false
gem 'aws-sdk', '~> 2.10', require: false
gem 'fog-core', '~> 1.45'
gem 'fog-local', '~> 0.4', require: false
gem 'fog-openstack', '~> 0.1', require: false
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'posix-spawn'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5'
@@ -28,7 +29,7 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.5'
gem 'iso-639'
gem 'cld3', '~> 3.2.0'
gem 'devise', '~> 4.2'
gem 'devise', '~> 4.3'
gem 'devise-two-factor', '~> 3.0'
gem 'doorkeeper', '~> 4.2'
gem 'fast_blank', '~> 1.0'
@@ -58,6 +59,7 @@ gem 'redis', '~> 3.3', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 0.10'
gem 'ruby-oembed', '~> 0.12', require: 'oembed'
gem 'ruby-progressbar', '~> 1.4'
gem 'sanitize', '~> 4.4'
gem 'sidekiq', '~> 5.0'
gem 'sidekiq-scheduler', '~> 2.1'

View File

@@ -57,6 +57,14 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (2.10.100)
aws-sdk-resources (= 2.10.100)
aws-sdk-core (2.10.100)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-resources (2.10.100)
aws-sdk-core (= 2.10.100)
aws-sigv4 (1.0.2)
bcrypt (3.1.11)
better_errors (2.4.0)
coderay (>= 1.0.0)
@@ -152,11 +160,6 @@ GEM
i18n (~> 0.5)
fast_blank (1.0.0)
ffi (1.9.18)
fog-aws (1.4.1)
fog-core (~> 1.38)
fog-json (~> 1.0)
fog-xml (~> 0.1)
ipaddress (~> 0.8)
fog-core (1.45.0)
builder
excon (~> 0.58)
@@ -170,9 +173,6 @@ GEM
fog-core (>= 1.40)
fog-json (>= 1.0)
ipaddress (>= 0.8)
fog-xml (0.1.3)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
formatador (0.2.5)
fuubar (2.2.0)
rspec-core (~> 3.0)
@@ -228,6 +228,7 @@ GEM
idn-ruby (0.1.0)
ipaddress (0.8.3)
iso-639 (0.2.8)
jmespath (1.3.1)
json (2.1.0)
json-ld (2.1.7)
multi_json (~> 1.12)
@@ -324,6 +325,7 @@ GEM
pghero (1.7.0)
activerecord
pkg-config (1.2.8)
posix-spawn (0.3.13)
powerpack (0.1.1)
pry (0.11.3)
coderay (~> 1.1.0)
@@ -544,6 +546,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.5)
addressable (~> 2.5)
annotate (~> 2.7)
aws-sdk (~> 2.10)
better_errors (~> 2.4)
binding_of_caller (~> 0.7)
bootsnap
@@ -559,14 +562,13 @@ DEPENDENCIES
charlock_holmes (~> 0.7.5)
cld3 (~> 3.2.0)
climate_control (~> 0.2)
devise (~> 4.2)
devise (~> 4.3)
devise-two-factor (~> 3.0)
doorkeeper (~> 4.2)
dotenv-rails (~> 2.2)
fabrication (~> 2.18)
faker (~> 1.7)
fast_blank (~> 1.0)
fog-aws (~> 1.4)
fog-core (~> 1.45)
fog-local (~> 0.4)
fog-openstack (~> 0.1)
@@ -601,6 +603,7 @@ DEPENDENCIES
pg (~> 0.20)
pghero (~> 1.7)
pkg-config (~> 1.2)
posix-spawn
pry-rails (~> 0.3)
puma (~> 3.10)
pundit (~> 1.1)
@@ -620,6 +623,7 @@ DEPENDENCIES
rspec-sidekiq (~> 3.0)
rubocop
ruby-oembed (~> 0.12)
ruby-progressbar (~> 1.4)
sanitize (~> 4.4)
scss_lint (~> 0.55)
sidekiq (~> 5.0)
@@ -642,4 +646,4 @@ RUBY VERSION
ruby 2.4.2p198
BUNDLED WITH
1.16.0
1.16.1

View File

@@ -49,7 +49,7 @@ class AccountsController < ApplicationController
end
def default_statuses
@account.statuses.where(visibility: [:public, :unlisted])
@account.statuses.not_local_only.where(visibility: [:public, :unlisted])
end
def only_media_scope

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class ActivityPub::FollowsController < Api::BaseController
include SignatureVerification
def show
render(
json: FollowRequest.includes(:account).references(:account).find_by!(
id: params.require(:id),
accounts: { domain: nil, username: params.require(:account_username) },
target_account: signed_request_account
),
serializer: ActivityPub::FollowSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
)
end
end

View File

@@ -89,7 +89,8 @@ module Admin
:username,
:display_name,
:email,
:ip
:ip,
:staff
)
end
end

View File

@@ -3,6 +3,7 @@
module Admin
class CustomEmojisController < BaseController
before_action :set_custom_emoji, except: [:index, :new, :create]
before_action :set_filter_params
def index
authorize :custom_emoji, :index?
@@ -32,23 +33,26 @@ module Admin
if @custom_emoji.update(resource_params)
log_action :update, @custom_emoji
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
flash[:notice] = I18n.t('admin.custom_emojis.updated_msg')
else
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
flash[:alert] = I18n.t('admin.custom_emojis.update_failed_msg')
end
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
def destroy
authorize @custom_emoji, :destroy?
@custom_emoji.destroy!
log_action :destroy, @custom_emoji
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
flash[:notice] = I18n.t('admin.custom_emojis.destroyed_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
def copy
authorize @custom_emoji, :copy?
emoji = CustomEmoji.find_or_initialize_by(domain: nil, shortcode: @custom_emoji.shortcode)
emoji = CustomEmoji.find_or_initialize_by(domain: nil,
shortcode: @custom_emoji.shortcode)
emoji.image = @custom_emoji.image
if emoji.save
@@ -58,21 +62,23 @@ module Admin
flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
end
redirect_to admin_custom_emojis_path(page: params[:page])
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
def enable
authorize @custom_emoji, :enable?
@custom_emoji.update!(disabled: false)
log_action :enable, @custom_emoji
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
def disable
authorize @custom_emoji, :disable?
@custom_emoji.update!(disabled: true)
log_action :disable, @custom_emoji
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
private
@@ -81,6 +87,10 @@ module Admin
@custom_emoji = CustomEmoji.find(params[:id])
end
def set_filter_params
@filter_params = filter_params.to_hash.symbolize_keys
end
def resource_params
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
end
@@ -92,7 +102,9 @@ module Admin
def filter_params
params.permit(
:local,
:remote
:remote,
:by_domain,
:shortcode
)
end
end

View File

@@ -17,6 +17,8 @@ module Admin
bootstrap_timeline_accounts
thumbnail
min_invite_role
activity_api_enabled
peers_api_enabled
).freeze
BOOLEAN_SETTINGS = %w(
@@ -24,6 +26,8 @@ module Admin
open_deletion
timeline_preview
show_staff_badge
activity_api_enabled
peers_api_enabled
).freeze
UPLOAD_SETTINGS = %w(

View File

@@ -72,19 +72,4 @@ class Api::BaseController < ApplicationController
def render_empty
render json: {}, status: 200
end
def set_maps(statuses) # rubocop:disable Style/AccessorMethodName
if current_account.nil?
@reblogs_map = {}
@favourites_map = {}
@mutes_map = {}
return
end
status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
@reblogs_map = Status.reblogs_map(status_ids, current_account)
@favourites_map = Status.favourites_map(status_ids, current_account)
@mutes_map = Status.mutes_map(conversation_ids, current_account)
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
class Api::V1::Accounts::ListsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
before_action :set_account
respond_to :json
def index
@lists = @account.lists.where(account: current_account)
render json: @lists, each_serializer: REST::ListSerializer
end
private
def set_account
@account = Account.find(params[:account_id])
end
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
class Api::V1::Instances::ActivityController < Api::BaseController
before_action :require_enabled_api!
respond_to :json
def show
render_cached_json('api:v1:instances:activity:show', expires_in: 1.day) { activity }
end
private
def activity
weeks = []
12.times do |i|
day = i.weeks.ago.to_date
week_id = day.cweek
week = Date.commercial(day.cwyear, week_id)
weeks << {
week: week.to_time.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || 0,
logins: Redis.current.pfcount("activity:logins:#{week_id}"),
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || 0,
}
end
weeks
end
def require_enabled_api!
head 404 unless Setting.activity_api_enabled
end
end

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
class Api::V1::Instances::PeersController < Api::BaseController
before_action :require_enabled_api!
respond_to :json
def index
render_cached_json('api:v1:instances:peers:index', expires_in: 1.day) { Account.remote.domains }
end
private
def require_enabled_api!
head 404 unless Setting.peers_api_enabled
end
end

View File

@@ -1,18 +1,14 @@
# frozen_string_literal: true
class Api::V1::ListsController < Api::BaseController
LISTS_LIMIT = 50
before_action -> { doorkeeper_authorize! :read }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write }, except: [:index, :show]
before_action :require_user!
before_action :set_list, except: [:index, :create]
after_action :insert_pagination_headers, only: :index
def index
@lists = List.where(account: current_account).paginate_by_max_id(limit_param(LISTS_LIMIT), params[:max_id], params[:since_id])
@lists = List.where(account: current_account).all
render json: @lists, each_serializer: REST::ListSerializer
end
@@ -44,36 +40,4 @@ class Api::V1::ListsController < Api::BaseController
def list_params
params.permit(:title)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
if records_continue?
api_v1_lists_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless @lists.empty?
api_v1_lists_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
@lists.last.id
end
def pagination_since_id
@lists.first.id
end
def records_continue?
@lists.size == limit_param(LISTS_LIMIT)
end
def pagination_params(core_params)
params.permit(:limit).merge(core_params)
end
end

View File

@@ -28,6 +28,8 @@ class Api::Web::PushSubscriptionsController < Api::BaseController
},
}
data.deep_merge!(params[:data]) if params[:data]
web_subscription = ::Web::PushSubscription.create!(
endpoint: params[:subscription][:endpoint],
key_p256dh: params[:subscription][:keys][:p256dh],

View File

@@ -196,4 +196,13 @@ class ApplicationController < ActionController::Base
end
end
end
def render_cached_json(cache_key, **options)
data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do
yield.to_json
end
expires_in options[:expires_in], public: true
render json: data
end
end

View File

@@ -2,14 +2,9 @@
class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth'
before_action :set_pack
def show
super do |user|
BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty?
end
end
private
def set_pack

View File

@@ -38,6 +38,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
new_user_session_path
end
def after_update_path_for(_resource)
edit_user_registration_path
end
def check_enabled_registrations
redirect_to root_path if single_user_mode? || !allowed_registrations?
end

View File

@@ -8,8 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create]
skip_before_action :check_suspension, only: [:destroy]
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
prepend_before_action :set_pack
before_action :set_instance_presenter, only: [:new]
before_action :set_pack
def create
super do |resource|

View File

@@ -5,6 +5,7 @@ class AuthorizeFollowsController < ApplicationController
before_action :authenticate_user!
before_action :set_pack
before_action :set_body_classes
def show
@account = located_account || render(:error)
@@ -63,4 +64,8 @@ class AuthorizeFollowsController < ApplicationController
def acct_params
params.fetch(:acct, '')
end
def set_body_classes
@body_classes = 'modal-layout'
end
end

View File

@@ -44,7 +44,8 @@ module RateLimitHeaders
end
def api_throttle_data
request.env['rack.attack.throttle_data']['api']
most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] }
request.env['rack.attack.throttle_data'][most_limited_type]
end
def request_time

View File

@@ -17,6 +17,7 @@ module UserTrackingConcern
# Mark as signed-in today
current_user.update_tracked_fields!(request)
ActivityTracker.record('activity:logins', current_user.id)
# Regenerate feed if needed
regenerate_feed! if user_needs_feed_update?

View File

@@ -5,6 +5,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :set_pack
include Localized
@@ -13,4 +14,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
def store_current_location
store_location_for(:user, request.url)
end
def set_pack
use_pack 'auth'
end
end

View File

@@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :set_pack
include Localized
@@ -13,4 +14,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
def store_current_location
store_location_for(:user, request.url)
end
def set_pack
use_pack 'settings'
end
end

View File

@@ -43,4 +43,8 @@ class RemoteFollowController < ApplicationController
def suspended_account?
@account.suspended?
end
def set_body_classes
@body_classes = 'modal-layout'
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
class Settings::FlavoursController < Settings::BaseController
def index
redirect_to action: 'show', flavour: current_flavour
end
def show
unless Themes.instance.flavours.include?(params[:flavour]) or params[:flavour] == current_flavour
redirect_to action: 'show', flavour: current_flavour
end
@listing = Themes.instance.flavours
@selected = params[:flavour]
end
def update
user_settings.update(user_settings_params(params[:flavour]).to_h)
redirect_to action: 'show', flavour: params[:flavour]
end
private
def user_settings
UserSettingsDecorator.new(current_user)
end
def user_settings_params(flavour)
params.require(:user).merge({ setting_flavour: flavour }).permit(
:setting_flavour,
:setting_skin
)
end
end

View File

@@ -28,6 +28,7 @@ class Settings::MigrationsController < ApplicationController
end
def migration_account_changed?
current_account.moved_to_account_id != @migration.account&.id
current_account.moved_to_account_id != @migration.account&.id &&
current_account.id != @migration.account&.id
end
end

View File

@@ -33,13 +33,12 @@ class Settings::PreferencesController < Settings::BaseController
:setting_default_sensitive,
:setting_unfollow_modal,
:setting_boost_modal,
:setting_favourite_modal,
:setting_delete_modal,
:setting_auto_play_gif,
:setting_reduce_motion,
:setting_system_font_ui,
:setting_noindex,
:setting_flavour,
:setting_skin,
notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following)
)

View File

@@ -2,11 +2,7 @@
module Settings
module TwoFactorAuthentication
class ConfirmationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
class ConfirmationsController < BaseController
def new
prepare_two_factor_form
end

View File

@@ -2,11 +2,7 @@
module Settings
module TwoFactorAuthentication
class RecoveryCodesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
class RecoveryCodesController < BaseController
def create
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!

View File

@@ -30,6 +30,6 @@ class SharesController < ApplicationController
end
def set_body_classes
@body_classes = 'compose-standalone'
@body_classes = 'modal-layout compose-standalone'
end
end

View File

@@ -10,6 +10,7 @@ class StatusesController < ApplicationController
before_action :set_link_headers
before_action :check_account_suspension
before_action :redirect_to_original, only: [:show]
before_action { response.headers['Vary'] = 'Accept' }
def show
respond_to do |format|
@@ -26,6 +27,12 @@ class StatusesController < ApplicationController
serializer: ActivityPub::NoteSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
# Allow HTTP caching for 3 minutes if the status is public
unless @stream_entry.hidden?
request.session_options[:skip] = true
expires_in(3.minutes, public: true)
end
end
end
end

View File

@@ -1,15 +1,19 @@
# frozen_string_literal: true
module WellKnown
class HostMetaController < ApplicationController
class HostMetaController < ActionController::Base
include RoutingHelper
before_action { response.headers['Vary'] = 'Accept' }
def show
@webfinger_template = "#{webfinger_url}?resource={uri}"
respond_to do |format|
format.xml { render content_type: 'application/xrd+xml' }
end
expires_in(3.days, public: true)
end
end
end

View File

@@ -1,9 +1,11 @@
# frozen_string_literal: true
module WellKnown
class WebfingerController < ApplicationController
class WebfingerController < ActionController::Base
include RoutingHelper
before_action { response.headers['Vary'] = 'Accept' }
def show
@account = Account.find_local!(username_from_resource)
@@ -16,6 +18,8 @@ module WellKnown
render content_type: 'application/xrd+xml'
end
end
expires_in(3.days, public: true)
rescue ActiveRecord::RecordNotFound
head 404
end

View File

@@ -1,11 +1,12 @@
# frozen_string_literal: true
module Admin::FilterHelper
ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip).freeze
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze
ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip staff).freeze
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
new_url = filtered_url_for(link_to_params)

View File

@@ -10,6 +10,7 @@ module SettingsHelper
eo: 'Esperanto',
es: 'Español',
fa: 'فارسی',
gl: 'Galego',
fi: 'Suomi',
fr: 'Français',
he: 'עברית',
@@ -27,6 +28,9 @@ module SettingsHelper
pt: 'Português',
'pt-BR': 'Português do Brasil',
ru: 'Русский',
sk: 'Slovensky',
sr: 'Српски',
'sr-Latn': 'Srpski (latinica)',
sv: 'Svenska',
th: 'ภาษาไทย',
tr: 'Türkçe',

View File

@@ -37,7 +37,3 @@ delegate(document, '#account_header', 'change', ({ target }) => {
header.style.backgroundImage = `url(${url})`;
});
delegate(document, '#user_setting_flavour, #user_setting_skin', 'change', ({ target }) => {
target.form.submit();
});

View File

@@ -38,7 +38,6 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
@@ -316,21 +315,14 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
export function selectComposeSuggestion(position, token, suggestion) {
return (dispatch, getState) => {
let completion, startPosition;
if (typeof suggestion === 'object' && suggestion.id) {
completion = suggestion.native || suggestion.colons;
startPosition = position - 1;
dispatch(useEmoji(suggestion));
} else {
completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position;
}
const completion = typeof suggestion === 'object' && suggestion.id ? (
dispatch(useEmoji(suggestion)),
suggestion.native || suggestion.colons
) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position: startPosition,
position,
token,
completion,
});
@@ -389,10 +381,3 @@ export function insertEmojiCompose(position, emoji) {
emoji,
};
};
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
value,
};
}

View File

@@ -27,8 +27,10 @@ export default class Account extends ImmutablePureComponent {
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
small: PropTypes.bool,
};
handleFollow = () => {
@@ -52,7 +54,12 @@ export default class Account extends ImmutablePureComponent {
}
render () {
const { account, intl, hidden } = this.props;
const {
account,
hidden,
intl,
small,
} = this.props;
if (!account) {
return <div />;
@@ -69,7 +76,7 @@ export default class Account extends ImmutablePureComponent {
let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) {
if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
@@ -97,17 +104,23 @@ export default class Account extends ImmutablePureComponent {
}
}
return (
return small ? (
<div className='account small'>
<div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div>
<DisplayName account={account} />
</div>
) : (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
<div className='account__relationship'>
{buttons}
</div>
{buttons ?
<div className='account__relationship'>
{buttons}
</div>
: null}
</div>
</div>
);

View File

@@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || '';
export default class AutosuggestEmoji extends React.PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,
};
render () {
const { emoji } = this.props;
let url;
if (emoji.custom) {
url = emoji.imageUrl;
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
{emoji.colons}
</div>
);
}
}

View File

@@ -1,223 +0,0 @@
import React from 'react';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from 'flavours/glitch/util/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export default class AutosuggestTextarea extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
};
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
}
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
switch(e.key) {
case 'Escape':
if (!suggestionsHidden) {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onKeyUp = e => {
if (e.key === 'Escape' && this.state.suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
}
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
onBlur = () => {
this.setState({ suggestionsHidden: true });
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
}
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return (
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);
}
}

View File

@@ -1,19 +1,22 @@
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
export default class Avatar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
className: PropTypes.string,
size: PropTypes.number.isRequired,
style: PropTypes.object,
animate: PropTypes.bool,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
animate: false,
animate: autoPlayGif,
size: 20,
inline: false,
};
@@ -33,17 +36,19 @@ export default class Avatar extends React.PureComponent {
}
render () {
const { account, size, animate, inline } = this.props;
const {
account,
animate,
className,
inline,
size,
} = this.props;
const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
let className = 'account__avatar';
if (inline) {
className = className + ' account__avatar-inline';
}
const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
const style = {
...this.props.style,
@@ -60,7 +65,7 @@ export default class Avatar extends React.PureComponent {
return (
<div
className={className}
className={computedClass}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}

View File

@@ -1,22 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
};
render() {
const { account, friend } = this.props;
const { account, friend, animate } = this.props;
const baseStyle = {
backgroundImage: `url(${account.get('avatar_static')})`,
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
const overlayStyle = {
backgroundImage: `url(${friend.get('avatar_static')})`,
backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (

View File

@@ -1,3 +1,5 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
@@ -5,13 +7,19 @@ export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
className: PropTypes.string,
};
render () {
const displayNameHtml = { __html: this.props.account.get('display_name_html') };
const {
account,
className,
} = this.props;
const computedClass = classNames('display-name', className);
const displayNameHtml = { __html: account.get('display_name_html') };
return (
<span className='display-name'>
<span className={computedClass}>
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
</span>
);

View File

@@ -133,8 +133,13 @@ export default class Dropdown extends React.PureComponent {
this.props.onModalOpen({
status,
actions: items,
onClick: this.handleItemClick,
actions: items.map(
(item, i) => item ? {
...item,
name: `${item.text}-${i}`,
onClick: this.handleItemClick.bind(i),
} : null
),
});
return;
@@ -162,8 +167,7 @@ export default class Dropdown extends React.PureComponent {
}
}
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
handleItemClick = (i, e) => {
const { action, to } = this.props.items[i];
this.handleClose();

View File

@@ -0,0 +1,26 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// This just renders a FontAwesome icon.
export default function Icon ({
className,
fullwidth,
icon,
}) {
const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
return icon ? (
<span
aria-hidden='true'
className={computedClass}
/>
) : null;
}
// Props.
Icon.propTypes = {
className: PropTypes.string,
fullwidth: PropTypes.bool,
icon: PropTypes.string,
};

View File

@@ -0,0 +1,97 @@
// Inspired by <CommonLink> from Mastodon GO!
// ~ 😘 kibi!
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// We don't handle clicks that are made with modifiers, since these
// often have special browser meanings (eg, "open in new tab").
click (e) {
const { onClick } = this.props;
if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
onClick(e);
e.preventDefault(); // Prevents following of the link
},
};
// The component.
export default class Link extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { click } = this.handlers;
const {
children,
className,
href,
onClick,
role,
title,
...rest
} = this.props;
const computedClass = classNames('link', className, `role-${role}`);
// We assume that our `onClick` is a routing function and give it
// the qualities of a link even if no `href` is provided. However,
// if we have neither an `onClick` or an `href`, our link is
// purely presentational.
const conditionalProps = {};
if (href) {
conditionalProps.href = href;
conditionalProps.onClick = click;
} else if (onClick) {
conditionalProps.onClick = click;
conditionalProps.role = 'link';
conditionalProps.tabIndex = 0;
} else {
conditionalProps.role = 'presentation';
}
// If we were provided a `role` it overwrites any that we may have
// set above. This can be used for "links" which are actually
// buttons.
if (role) {
conditionalProps.role = role;
}
// Rendering. We set `rel='noopener'` for user privacy, and our
// `target` as `'_blank'`.
return (
<a
className={computedClass}
{...conditionalProps}
rel='noopener'
target='_blank'
title={title}
{...rest}
>{children}</a>
);
}
}
// Props.
Link.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
href: PropTypes.string, // The link destination
onClick: PropTypes.func, // A function to call instead of opening the link
role: PropTypes.string, // An ARIA role for the link
title: PropTypes.string, // A title for the link
};

View File

@@ -6,24 +6,24 @@ export default class SettingText extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingKey: PropTypes.array.isRequired,
settingPath: PropTypes.array.isRequired,
label: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = (e) => {
this.props.onChange(this.props.settingKey, e.target.value);
this.props.onChange(this.props.settingPath, e.target.value);
}
render () {
const { settings, settingKey, label } = this.props;
const { settings, settingPath, label } = this.props;
return (
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={settings.getIn(settingKey)}
value={settings.getIn(settingPath)}
onChange={this.handleChange}
placeholder={label}
/>

View File

@@ -58,6 +58,7 @@ export default class Status extends ImmutablePureComponent {
'settings',
'prepend',
'boostModal',
'favouriteModal',
'muted',
'collapse',
'notification',
@@ -204,8 +205,8 @@ export default class Status extends ImmutablePureComponent {
this.props.onReply(this.props.status, this.context.router.history);
}
handleHotkeyFavourite = () => {
this.props.onFavourite(this.props.status);
handleHotkeyFavourite = (e) => {
this.props.onFavourite(this.props.status, e);
}
handleHotkeyBoost = e => {

View File

@@ -71,8 +71,8 @@ export default class StatusActionBar extends ImmutablePureComponent {
});
}
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
handleFavouriteClick = (e) => {
this.props.onFavourite(this.props.status, e);
}
handleReblogClick = (e) => {

View File

@@ -20,7 +20,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initReport } from 'flavours/glitch/actions/reports';
import { openModal } from 'flavours/glitch/actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state';
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -78,11 +78,19 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onFavourite (status) {
onModalFavourite (status) {
dispatch(favourite(status));
},
onFavourite (status, e) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
if (e.shiftKey || !favouriteModal) {
this.onModalFavourite(status);
} else {
dispatch(openModal('FAVOURITE', { status, onFavourite: this.onModalFavourite }));
}
}
},

View File

@@ -63,7 +63,15 @@ export default class Header extends ImmutablePureComponent {
<div className='account__header__wrapper'>
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<Avatar account={account} size={90} />
<a
href={account.get('url')}
className='account__header__avatar'
role='presentation'
target='_blank'
rel='noopener'
>
<Avatar account={account} size={90} />
</a>
<span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
<span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>

View File

@@ -26,7 +26,7 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
);

View File

@@ -1,62 +0,0 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports.
import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
import ComposeDropdown from './dropdown';
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' },
});
@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,
};
render () {
const { intl, values } = this.props;
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
];
const anyEnabled = values.some((enabled) => enabled);
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)}
/>
);
});
return (
<ComposeDropdown
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='home'
highlight={anyEnabled}
>
{optionElems}
</ComposeDropdown>
);
}
}

View File

@@ -1,35 +0,0 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import Toggle from 'react-toggle';
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 = () => {
this.props.onChange(this.props.name);
}
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

@@ -1,131 +0,0 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports //
import ComposeDropdown from './dropdown';
import { uploadCompose } from 'flavours/glitch/actions/compose';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { openModal } from 'flavours/glitch/actions/modal';
const messages = defineMessages({
upload :
{ id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
doodle :
{ id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
attach :
{ id: 'compose.attach', defaultMessage: 'Attach...' },
});
const mapStateToProps = state => ({
// This horrible expression is copied from vanilla upload_button_container
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
});
const mapDispatchToProps = dispatch => ({
onSelectFile (files) {
dispatch(uploadCompose(files));
},
onOpenDoodle () {
dispatch(openModal('DOODLE', { noEsc: true }));
},
});
@injectIntl
@connect(mapStateToProps, mapDispatchToProps)
export default class ComposeAttachOptions extends ImmutablePureComponent {
static propTypes = {
intl : PropTypes.object.isRequired,
resetFileKey: PropTypes.number,
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
disabled: PropTypes.bool,
onSelectFile: PropTypes.func.isRequired,
onOpenDoodle: PropTypes.func.isRequired,
};
handleItemClick = bt => {
if (bt === 'upload') {
this.fileElement.click();
}
if (bt === 'doodle') {
this.props.onOpenDoodle();
}
this.dropdown.setState({ open: false });
};
handleFileChange = (e) => {
if (e.target.files.length > 0) {
this.props.onSelectFile(e.target.files);
}
}
setFileRef = (c) => {
this.fileElement = c;
}
setDropdownRef = (c) => {
this.dropdown = c;
}
render () {
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
const options = [
{ icon: 'cloud-upload', text: messages.upload, name: 'upload' },
{ icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
];
const optionElems = options.map((item) => {
const hdl = () => this.handleItemClick(item.name);
return (
<div
role='button'
tabIndex='0'
key={item.name}
onClick={hdl}
className='privacy-dropdown__option'
>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{intl.formatMessage(item.text)}</strong>
</div>
</div>
);
});
return (
<div>
<ComposeDropdown
title={intl.formatMessage(messages.attach)}
icon='paperclip'
disabled={disabled}
ref={this.setDropdownRef}
>
{optionElems}
</ComposeDropdown>
<input
key={resetFileKey}
ref={this.setFileRef}
type='file'
multiple={false}
accept={acceptContentTypes.toArray().join(',')}
onChange={this.handleFileChange}
disabled={disabled}
style={{ display: 'none' }}
/>
</div>
);
}
}

View File

@@ -1,24 +0,0 @@
import React from 'react';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class AutosuggestAccount extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='autosuggest-account'>
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
<DisplayName account={account} />
</div>
);
}
}

View File

@@ -1,25 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { length } from 'stringz';
export default class CharacterCounter extends React.PureComponent {
static propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};
checkRemainingText (diff) {
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
}
render () {
const diff = this.props.max - length(this.props.text);
return this.checkRemainingText(diff);
}
}

View File

@@ -1,286 +0,0 @@
import React from 'react';
import CharacterCounter from './character_counter';
import Button from 'flavours/glitch/components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from 'flavours/glitch/components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from 'flavours/glitch/util/counter';
import ComposeAttachOptions from './attach_options';
import initialState from 'flavours/glitch/util/initial_state';
const maxChars = initialState.max_toot_chars;
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
});
@injectIntl
export default class ComposeForm extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string.isRequired,
suggestion_token: PropTypes.string,
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),
is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onPrivacyChange: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
settings : ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
showSearch: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
handleSubmit2 = () => {
this.props.onPrivacyChange(this.props.settings.get('side_arm'));
this.handleSubmit();
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
this.props.onSubmit();
}
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this._restoreCaret = null;
this.props.onSuggestionSelected(tokenStart, token, value);
}
handleChangeSpoilerText = (e) => {
this.props.onChangeSpoilerText(e.target.value);
}
componentWillReceiveProps (nextProps) {
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
if (!nextProps.is_uploading && this.props.is_uploading) {
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
}
}
componentDidUpdate (prevProps) {
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end of the textbox.
// - Replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved caret position,
// restores the cursor to that position after the text changes!
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
let selectionEnd, selectionStart;
if (this.props.preselectDate !== prevProps.preselectDate) {
selectionEnd = this.props.text.length;
selectionStart = this.props.text.search(/\s/) + 1;
} else if (typeof this._restoreCaret === 'number') {
selectionStart = this._restoreCaret;
selectionEnd = this._restoreCaret;
} else {
selectionEnd = this.props.text.length;
selectionStart = selectionEnd;
}
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
} else if(prevProps.is_submitting && !this.props.is_submitting) {
this.autosuggestTextarea.textarea.focus();
}
}
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
}
handleEmojiPick = (data) => {
const position = this.autosuggestTextarea.textarea.selectionStart;
const emojiChar = data.native;
this._restoreCaret = position + emojiChar.length + 1;
this.props.onPickEmoji(position, data);
}
render () {
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.is_submitting;
const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
const secondaryVisibility = this.props.settings.get('side_arm');
let showSideArm = secondaryVisibility !== 'none';
let publishText = '';
let publishText2 = '';
let title = '';
let title2 = '';
const privacyIcons = {
none: '',
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
};
title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
if (showSideArm) {
// Enhanced behavior with dual toot buttons
publishText = (
<span>
{
<i
className={`fa fa-${privacyIcons[this.props.privacy]}`}
style={{ paddingRight: '5px' }}
/>
}{intl.formatMessage(messages.publish)}
</span>
);
title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
publishText2 = (
<i
className={`fa fa-${privacyIcons[secondaryVisibility]}`}
aria-label={title2}
/>
);
} else {
// Original vanilla behavior - no icon if public or unlisted
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
}
const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
return (
<div className='compose-form'>
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
<div className='spoiler-input'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' />
</label>
</div>
</Collapsable>
<WarningContainer />
<ReplyIndicatorContainer />
<div className='compose-form__autosuggest-wrapper'>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth)}
/>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
</div>
<div className='compose-form__modifiers'>
<UploadFormContainer />
</div>
<div className='compose-form__buttons'>
<ComposeAttachOptions />
<SensitiveButtonContainer />
<div className='compose-form__buttons-separator' />
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<ComposeAdvancedOptionsContainer />
</div>
<div className='compose-form__publish'>
<div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
<div className='compose-form__publish-button-wrapper'>
{
showSideArm ?
<Button
className='compose-form__publish__side-arm'
text={publishText2}
title={title2}
onClick={this.handleSubmit2}
disabled={submitDisabled}
/> : ''
}
<Button
className='compose-form__publish__primary'
text={publishText}
title={title}
onClick={this.handleSubmit}
disabled={submitDisabled}
/>
</div>
</div>
</div>
);
}
}

View File

@@ -1,77 +0,0 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
// Our imports.
import IconButton from 'flavours/glitch/components/icon_button';
const iconStyle = {
height : null,
lineHeight : '27px',
};
export default class ComposeDropdown extends React.PureComponent {
static propTypes = {
title: PropTypes.string.isRequired,
icon: PropTypes.string,
highlight: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
state = {
open: false,
};
onGlobalClick = (e) => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
};
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
onToggleDropdown = () => {
if (this.props.disabled) return;
this.setState({ open: !this.state.open });
};
setRef = (c) => {
this.node = c;
};
render () {
const { open } = this.state;
let { highlight, title, icon, disabled } = this.props;
if (!icon) icon = 'ellipsis-h';
return (
<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${highlight ? 'active' : ''} `}>
<div className='advanced-options-dropdown__value'>
<IconButton
className={'inverted'}
title={title}
icon={icon} active={open || highlight}
size={18}
style={iconStyle}
disabled={disabled}
onClick={this.onToggleDropdown}
/>
</div>
<div className='advanced-options-dropdown__dropdown'>
{this.props.children}
</div>
</div>
);
}
}

View File

@@ -1,38 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from 'flavours/glitch/components/avatar';
import IconButton from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
};
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={40} />
</Permalink>
<div className='navigation-bar__profile'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
</Permalink>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
<IconButton title='' icon='close' onClick={this.props.onClose} />
</div>
);
}
}

View File

@@ -1,200 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
});
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
class PrivacyDropdownMenu extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleClick = e => {
if (e.key === 'Escape') {
this.props.onClose();
} else if (!e.key || e.key === 'Enter') {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render () {
const { style, items, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
{items.map(item =>
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
)}
</div>
)}
</Motion>
);
}
}
@injectIntl
export default class PrivacyDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
};
handleToggle = () => {
if (this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
this.setState({ open: !this.state.open });
}
}
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
}
handleKeyDown = e => {
switch(e.key) {
case 'Enter':
this.handleToggle();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleClose = () => {
this.setState({ open: false });
}
handleChange = value => {
this.props.onChange(value);
}
componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
];
}
render () {
const { value, intl } = this.props;
const { open } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
title={intl.formatMessage(messages.change_privacy)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
<Overlay show={open} placement='bottom' target={this}>
<PrivacyDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div>
);
}
}

View File

@@ -1,63 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from 'flavours/glitch/components/avatar';
import IconButton from 'flavours/glitch/components/icon_button';
import DisplayName from 'flavours/glitch/components/display_name';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
@injectIntl
export default class ReplyIndicator extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onCancel();
}
handleAccountClick = (e) => {
if (e.button === 0) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
}
render () {
const { status, intl } = this.props;
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
</div>
);
}
}

View File

@@ -1,129 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
});
class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () {
const { style } = this.props;
return (
<div style={{ ...style, position: 'absolute', width: 285 }}>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul>
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul>
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
</div>
)}
</Motion>
</div>
);
}
}
@injectIntl
export default class Search extends React.PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
submitted: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
expanded: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleClear = (e) => {
e.preventDefault();
if (this.props.value.length > 0 || this.props.submitted) {
this.props.onClear();
}
}
handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.props.onSubmit();
} else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
}
}
noop () {
}
handleFocus = () => {
this.setState({ expanded: true });
this.props.onShow();
}
handleBlur = () => {
this.setState({ expanded: false });
}
render () {
const { intl, value, submitted } = this.props;
const { expanded } = this.state;
const hasValue = value.length > 0 || submitted;
return (
<div className='search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<input
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
</label>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
</div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<SearchPopout />
</Overlay>
</div>
);
}
}

View File

@@ -1,65 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class SearchResults extends ImmutablePureComponent {
static propTypes = {
results: ImmutablePropTypes.map.isRequired,
};
render () {
const { results } = this.props;
let accounts, statuses, hashtags;
let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
<div className='search-results__section'>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
<div className='search-results__section'>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
hashtags = (
<div className='search-results__section'>
{results.get('hashtags').map(hashtag =>
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag}
</Link>
)}
</div>
);
}
return (
<div className='search-results'>
<div className='search-results__header'>
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
</div>
{accounts}
{statuses}
{hashtags}
</div>
);
}
}

View File

@@ -1,96 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'flavours/glitch/components/icon_button';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
@injectIntl
export default class Upload extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
};
state = {
hovered: false,
focused: false,
dirtyDescription: null,
};
handleUndoClick = () => {
this.props.onUndo(this.props.media.get('id'));
}
handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value });
}
handleMouseEnter = () => {
this.setState({ hovered: true });
}
handleMouseLeave = () => {
this.setState({ hovered: false });
}
handleInputFocus = () => {
this.setState({ focused: true });
}
handleInputBlur = () => {
const { dirtyDescription } = this.state;
this.setState({ focused: false, dirtyDescription: null });
if (dirtyDescription !== null) {
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
}
}
render () {
const { intl, media } = this.props;
const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || media.get('description') || '';
return (
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<input
placeholder={intl.formatMessage(messages.description)}
type='text'
value={description}
maxLength={420}
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
/>
</label>
</div>
</div>
)}
</Motion>
</div>
);
}
}

View File

@@ -1,77 +0,0 @@
import React from 'react';
import IconButton from 'flavours/glitch/components/icon_button';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
});
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
});
return mapStateToProps;
};
const iconStyle = {
height: null,
lineHeight: '27px',
};
@connect(makeMapStateToProps)
@injectIntl
export default class UploadButton extends ImmutablePureComponent {
static propTypes = {
disabled: PropTypes.bool,
onSelectFile: PropTypes.func.isRequired,
style: PropTypes.object,
resetFileKey: PropTypes.number,
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
intl: PropTypes.object.isRequired,
};
handleChange = (e) => {
if (e.target.files.length > 0) {
this.props.onSelectFile(e.target.files);
}
}
handleClick = () => {
this.fileElement.click();
}
setRef = (c) => {
this.fileElement = c;
}
render () {
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
return (
<div className='compose-form__upload-button'>
<IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
<input
key={resetFileKey}
ref={this.setRef}
type='file'
multiple={false}
accept={acceptContentTypes.toArray().join(',')}
onChange={this.handleChange}
disabled={disabled}
style={{ display: 'none' }}
/>
</label>
</div>
);
}
}

View File

@@ -1,29 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import UploadProgressContainer from '../containers/upload_progress_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import UploadContainer from '../containers/upload_container';
export default class UploadForm extends ImmutablePureComponent {
static propTypes = {
mediaIds: ImmutablePropTypes.list.isRequired,
};
render () {
const { mediaIds } = this.props;
return (
<div className='compose-form__upload-wrapper'>
<UploadProgressContainer />
<div className='compose-form__uploads-wrapper'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
</div>
);
}
}

View File

@@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl';
export default class UploadProgress extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
progress: PropTypes.number,
};
render () {
const { active, progress } = this.props;
if (!active) {
return null;
}
return (
<div className='upload-progress'>
<div className='upload-progress__icon'>
<i className='fa fa-upload' />
</div>
<div className='upload-progress__message'>
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
<div className='upload-progress__backdrop'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
<div className='upload-progress__tracker' style={{ width: `${width}%` }} />
}
</Motion>
</div>
</div>
</div>
);
}
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
export default class Warning extends React.PureComponent {
static propTypes = {
message: PropTypes.node.isRequired,
};
render () {
const { message } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
);
}
}

View File

@@ -1,20 +0,0 @@
// Package imports.
import { connect } from 'react-redux';
// Our imports.
import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose';
import ComposeAdvancedOptions from '../components/advanced_options';
const mapStateToProps = state => ({
values: state.getIn(['compose', 'advanced_options']),
});
const mapDispatchToProps = dispatch => ({
onChange (option) {
dispatch(toggleComposeAdvancedOption(option));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);

View File

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

View File

@@ -1,71 +0,0 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
} from 'flavours/glitch/actions/compose';
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']),
focusDate: state.getIn(['compose', 'focusDate']),
preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
settings: state.get('local_settings'),
filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
});
const mapDispatchToProps = (dispatch) => ({
onChange (text) {
dispatch(changeCompose(text));
},
onPrivacyChange (value) {
dispatch(changeComposeVisibility(value));
},
onSubmit () {
dispatch(submitCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId) {
dispatch(selectComposeSuggestion(position, token, accountId));
},
onChangeSpoilerText (checked) {
dispatch(changeComposeSpoilerText(checked));
},
onPaste (files) {
dispatch(uploadCompose(files));
},
onPickEmoji (position, data) {
dispatch(insertEmojiCompose(position, data));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);

View File

@@ -1,82 +0,0 @@
import { connect } from 'react-redux';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
import { useEmoji } from 'flavours/glitch/actions/emojis';
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort ) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
skinTone: state.getIn(['settings', 'skinTone']),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

View File

@@ -1,11 +0,0 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
import { me } from 'flavours/glitch/util/initial_state';
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
export default connect(mapStateToProps)(NavigationBar);

View File

@@ -1,24 +0,0 @@
import { connect } from 'react-redux';
import PrivacyDropdown from '../components/privacy_dropdown';
import { changeComposeVisibility } from 'flavours/glitch/actions/compose';
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
value: state.getIn(['compose', 'privacy']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => dispatch(closeModal()),
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View File

@@ -1,24 +0,0 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
import { makeGetStatus } from 'flavours/glitch/selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => ({
status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
});
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@@ -1,35 +0,0 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch,
} from 'flavours/glitch/actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit () {
dispatch(submitSearch());
},
onShow () {
dispatch(showSearch());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@@ -1,8 +0,0 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
});
export default connect(mapStateToProps)(SearchResults);

View File

@@ -1,71 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import IconButton from 'flavours/glitch/components/icon_button';
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' },
});
const mapStateToProps = state => ({
visible: state.getIn(['compose', 'media_attachments']).size > 0,
active: state.getIn(['compose', 'sensitive']),
disabled: state.getIn(['compose', 'spoiler']),
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSensitivity());
},
});
class SensitiveButton extends React.PureComponent {
static propTypes = {
visible: PropTypes.bool,
active: PropTypes.bool,
disabled: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { visible, active, disabled, onClick, intl } = this.props;
return (
<Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
{({ scale }) => {
const icon = active ? 'eye-slash' : 'eye';
const className = classNames('compose-form__sensitive-button', {
'compose-form__sensitive-button--visible': visible,
});
return (
<div className={className} style={{ transform: `scale(${scale})` }}>
<IconButton
className='compose-form__sensitive-button__icon'
title={intl.formatMessage(messages.title)}
icon={icon}
onClick={onClick}
size={18}
active={active}
disabled={disabled}
style={{ lineHeight: null, height: null }}
inverted
/>
</div>
);
}}
</Motion>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));

View File

@@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import TextIconButton from '../components/text_icon_button';
import { changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' },
});
const mapStateToProps = (state, { intl }) => ({
label: 'CW',
title: intl.formatMessage(messages.title),
active: state.getIn(['compose', 'spoiler']),
ariaControls: 'cw-spoiler-input',
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSpoilerness());
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));

View File

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

View File

@@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = dispatch => ({
onUndo: id => {
dispatch(undoUploadCompose(id));
},
onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, description));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

View File

@@ -1,8 +0,0 @@
import { connect } from 'react-redux';
import UploadForm from '../components/upload_form';
const mapStateToProps = state => ({
mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
});
export default connect(mapStateToProps)(UploadForm);

View File

@@ -1,9 +0,0 @@
import { connect } from 'react-redux';
import UploadProgress from '../components/upload_progress';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'is_uploading']),
progress: state.getIn(['compose', 'progress']),
});
export default connect(mapStateToProps)(UploadProgress);

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import Warning from '../components/warning';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { me } from 'flavours/glitch/util/initial_state';
const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
});
const WarningWrapper = ({ needsLockWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
}
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);

View File

@@ -1,126 +0,0 @@
import React from 'react';
import ComposeFormContainer from './containers/compose_form_container';
import NavigationContainer from './containers/navigation_container';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
import { openModal } from 'flavours/glitch/actions/modal';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { Link } from 'react-router-dom';
import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from 'flavours/glitch/actions/compose';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
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' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
});
const mapStateToProps = state => ({
columns: state.getIn(['settings', 'columns']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
});
@connect(mapStateToProps)
@injectIntl
export default class Compose extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
this.props.dispatch(mountCompose());
}
componentWillUnmount () {
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));
}
onBlur = () => {
this.props.dispatch(changeComposing(false));
}
render () {
const { multiColumn, showSearch, intl } = this.props;
let header = '';
if (multiColumn) {
const { columns } = this.props;
header = (
<nav className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link>
{!columns.some(column => column.get('id') === 'HOME') && (
<Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
)}
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link>
)}
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
<Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link>
)}
{!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 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}
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer />
</div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />
</div>
}
</Motion>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,423 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Actions.
import {
cancelReplyCompose,
changeCompose,
changeComposeSensitivity,
changeComposeSpoilerText,
changeComposeSpoilerness,
changeComposeVisibility,
changeUploadCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
insertEmojiCompose,
selectComposeSuggestion,
submitCompose,
toggleComposeAdvancedOption,
undoUploadCompose,
uploadCompose,
} from 'flavours/glitch/actions/compose';
import {
closeModal,
openModal,
} from 'flavours/glitch/actions/modal';
// Components.
import ComposerOptions from './options';
import ComposerPublisher from './publisher';
import ComposerReply from './reply';
import ComposerSpoiler from './spoiler';
import ComposerTextarea from './textarea';
import ComposerUploadForm from './upload_form';
import ComposerWarning from './warning';
// Utils.
import { countableText } from 'flavours/glitch/util/counter';
import { me } from 'flavours/glitch/util/initial_state';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { wrap } from 'flavours/glitch/util/redux_helpers';
// State mapping.
function mapStateToProps (state) {
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
amUnlocked: !state.getIn(['accounts', me, 'locked']),
doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
focusDate: state.getIn(['compose', 'focusDate']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isUploading: state.getIn(['compose', 'is_uploading']),
layout: state.getIn(['local_settings', 'layout']),
media: state.getIn(['compose', 'media_attachments']),
preselectDate: state.getIn(['compose', 'preselectDate']),
privacy: state.getIn(['compose', 'privacy']),
progress: state.getIn(['compose', 'progress']),
replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null,
replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
resetFileKey: state.getIn(['compose', 'resetFileKey']),
sideArm: state.getIn(['local_settings', 'side_arm']),
sensitive: state.getIn(['compose', 'sensitive']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
suggestionToken: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
text: state.getIn(['compose', 'text']),
};
};
// Dispatch mapping.
const mapDispatchToProps = {
onCancelReply: cancelReplyCompose,
onChangeDescription: changeUploadCompose,
onChangeSensitivity: changeComposeSensitivity,
onChangeSpoilerText: changeComposeSpoilerText,
onChangeSpoilerness: changeComposeSpoilerness,
onChangeText: changeCompose,
onChangeVisibility: changeComposeVisibility,
onClearSuggestions: clearComposeSuggestions,
onCloseModal: closeModal,
onFetchSuggestions: fetchComposeSuggestions,
onInsertEmoji: insertEmojiCompose,
onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
onSelectSuggestion: selectComposeSuggestion,
onSubmit: submitCompose,
onToggleAdvancedOption: toggleComposeAdvancedOption,
onUndoUpload: undoUploadCompose,
onUpload: uploadCompose,
};
// Handlers.
const handlers = {
// Changes the text value of the spoiler.
handleChangeSpoiler ({ target: { value } }) {
const { onChangeSpoilerText } = this.props;
if (onChangeSpoilerText) {
onChangeSpoilerText(value);
}
},
// Inserts an emoji at the caret.
handleEmoji (data) {
const { textarea: { selectionStart } } = this;
const { onInsertEmoji } = this.props;
this.caretPos = selectionStart + data.native.length + 1;
if (onInsertEmoji) {
onInsertEmoji(selectionStart, data);
}
},
// Handles the secondary submit button.
handleSecondarySubmit () {
const { handleSubmit } = this.handlers;
const {
onChangeVisibility,
sideArm,
} = this.props;
if (sideArm !== 'none' && onChangeVisibility) {
onChangeVisibility(sideArm);
}
handleSubmit();
},
// Selects a suggestion from the autofill.
handleSelect (tokenStart, token, value) {
const { onSelectSuggestion } = this.props;
this.caretPos = null;
if (onSelectSuggestion) {
onSelectSuggestion(tokenStart, token, value);
}
},
// Submits the status.
handleSubmit () {
const { textarea: { value } } = this;
const {
onChangeText,
onSubmit,
text,
} = this.props;
// If something changes inside the textarea, then we update the
// state before submitting.
if (onChangeText && text !== value) {
onChangeText(value);
}
// Submits the status.
if (onSubmit) {
onSubmit();
}
},
// Sets a reference to the textarea.
handleRefTextarea (textareaComponent) {
if (textareaComponent) {
this.textarea = textareaComponent.textarea;
}
},
};
// The component.
class Composer extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.caretPos = null;
this.textarea = null;
}
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
componentWillReceiveProps (nextProps) {
const { textarea } = this;
const { isUploading } = this.props;
if (textarea && isUploading && !nextProps.isUploading) {
this.caretPos = textarea.selectionStart;
}
}
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end
// of the textbox.
// - Replying to more than one user, selects any usernames past
// the first; this provides a convenient shortcut to drop
// everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved
// caret position, restores the cursor to that position after the
// text changes.
componentDidUpdate (prevProps) {
const {
caretPos,
textarea,
} = this;
const {
focusDate,
isUploading,
isSubmitting,
preselectDate,
text,
} = this.props;
let selectionEnd, selectionStart;
// Caret/selection handling.
if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
switch (true) {
case preselectDate !== prevProps.preselectDate:
selectionStart = text.search(/\s/) + 1;
selectionEnd = text.length;
break;
case !isNaN(caretPos) && caretPos !== null:
selectionStart = selectionEnd = caretPos;
break;
default:
selectionStart = selectionEnd = text.length;
}
if (textarea) {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
}
// Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
textarea.focus();
}
}
render () {
const {
handleChangeSpoiler,
handleEmoji,
handleSecondarySubmit,
handleSelect,
handleSubmit,
handleRefTextarea,
} = this.handlers;
const { history } = this.context;
const {
acceptContentTypes,
amUnlocked,
doNotFederate,
intl,
isSubmitting,
isUploading,
layout,
media,
onCancelReply,
onChangeDescription,
onChangeSensitivity,
onChangeSpoilerness,
onChangeText,
onChangeVisibility,
onClearSuggestions,
onCloseModal,
onFetchSuggestions,
onOpenActionsModal,
onOpenDoodleModal,
onToggleAdvancedOption,
onUndoUpload,
onUpload,
privacy,
progress,
replyAccount,
replyContent,
resetFileKey,
sensitive,
showSearch,
sideArm,
spoiler,
spoilerText,
suggestions,
text,
} = this.props;
return (
<div className='composer'>
<ComposerSpoiler
hidden={!spoiler}
intl={intl}
onChange={handleChangeSpoiler}
onSubmit={handleSubmit}
text={spoilerText}
/>
{privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
{replyContent ? (
<ComposerReply
account={replyAccount}
content={replyContent}
history={history}
intl={intl}
onCancel={onCancelReply}
/>
) : null}
<ComposerTextarea
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
disabled={isSubmitting}
intl={intl}
onChange={onChangeText}
onPaste={onUpload}
onPickEmoji={handleEmoji}
onSubmit={handleSubmit}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionSelected={handleSelect}
ref={handleRefTextarea}
suggestions={suggestions}
value={text}
/>
{isUploading || media && media.size ? (
<ComposerUploadForm
intl={intl}
media={media}
onChangeDescription={onChangeDescription}
onRemove={onUndoUpload}
progress={progress}
uploading={isUploading}
/>
) : null}
<ComposerOptions
acceptContentTypes={acceptContentTypes}
disabled={isSubmitting}
doNotFederate={doNotFederate}
full={media.size >= 4 || media.some(
item => item.get('type') === 'video'
)}
hasMedia={!!media.size}
intl={intl}
onChangeSensitivity={onChangeSensitivity}
onChangeVisibility={onChangeVisibility}
onDoodleOpen={onOpenDoodleModal}
onModalClose={onCloseModal}
onModalOpen={onOpenActionsModal}
onToggleAdvancedOption={onToggleAdvancedOption}
onToggleSpoiler={onChangeSpoilerness}
onUpload={onUpload}
privacy={privacy}
resetFileKey={resetFileKey}
sensitive={sensitive}
spoiler={spoiler}
/>
<ComposerPublisher
countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`}
disabled={isSubmitting || isUploading || !!text.length && !text.trim().length}
intl={intl}
onSecondarySubmit={handleSecondarySubmit}
onSubmit={handleSubmit}
privacy={privacy}
sideArm={sideArm}
/>
</div>
);
}
}
// Context
Composer.contextTypes = {
history: PropTypes.object,
};
// Props.
Composer.propTypes = {
intl: PropTypes.object.isRequired,
// State props.
acceptContentTypes: PropTypes.string,
amUnlocked: PropTypes.bool,
doNotFederate: PropTypes.bool,
focusDate: PropTypes.instanceOf(Date),
isSubmitting: PropTypes.bool,
isUploading: PropTypes.bool,
layout: PropTypes.string,
media: ImmutablePropTypes.list,
preselectDate: PropTypes.instanceOf(Date),
privacy: PropTypes.string,
progress: PropTypes.number,
replyAccount: ImmutablePropTypes.map,
replyContent: PropTypes.string,
resetFileKey: PropTypes.number,
sideArm: PropTypes.string,
sensitive: PropTypes.bool,
showSearch: PropTypes.bool,
spoiler: PropTypes.bool,
spoilerText: PropTypes.string,
suggestionToken: PropTypes.string,
suggestions: ImmutablePropTypes.list,
text: PropTypes.string,
// Dispatch props.
onCancelReply: PropTypes.func,
onChangeDescription: PropTypes.func,
onChangeSensitivity: PropTypes.func,
onChangeSpoilerText: PropTypes.func,
onChangeSpoilerness: PropTypes.func,
onChangeText: PropTypes.func,
onChangeVisibility: PropTypes.func,
onClearSuggestions: PropTypes.func,
onCloseModal: PropTypes.func,
onFetchSuggestions: PropTypes.func,
onInsertEmoji: PropTypes.func,
onOpenActionsModal: PropTypes.func,
onOpenDoodleModal: PropTypes.func,
onSelectSuggestion: PropTypes.func,
onSubmit: PropTypes.func,
onToggleAdvancedOption: PropTypes.func,
onUndoUpload: PropTypes.func,
onUpload: PropTypes.func,
};
// Connecting and export.
export { Composer as WrappedComponent };
export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);

View File

@@ -0,0 +1,138 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import spring from 'react-motion/lib/spring';
// Components.
import ComposerOptionsDropdownContentItem from './item';
// Utils.
import { withPassive } from 'flavours/glitch/util/dom_helpers';
import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick ({ target }) {
const { node } = this;
const { onClose } = this.props;
if (onClose && node && !node.contains(target)) {
onClose();
}
},
// Stores our node in `this.node`.
handleRef (node) {
this.node = node;
},
};
// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
stiffness: 400,
});
// The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.node = null;
}
// On mounting, we add our listeners.
componentDidMount () {
const { handleDocumentClick } = this.handlers;
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, withPassive);
}
// On unmounting, we remove our listeners.
componentWillUnmount () {
const { handleDocumentClick } = this.handlers;
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, withPassive);
}
// Rendering.
render () {
const { handleRef } = this.handlers;
const {
items,
onChange,
onClose,
style,
value,
} = this.props;
// The result.
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: springMotion,
scaleX: springMotion,
scaleY: springMotion,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='composer--options--dropdown--content'
ref={handleRef}
style={{
...style,
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
{items.map(
({
name,
...rest
}) => (
<ComposerOptionsDropdownContentItem
active={name === value}
key={name}
name={name}
onChange={onChange}
onClose={onClose}
options={rest}
/>
)
)}
</div>
)}
</Motion>
);
}
}
// Props.
ComposerOptionsDropdownContent.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
on: PropTypes.bool,
text: PropTypes.node,
})).isRequired,
onChange: PropTypes.func,
onClose: PropTypes.func,
style: PropTypes.object,
value: PropTypes.string,
};
// Default props.
ComposerOptionsDropdownContent.defaultProps = { style: {} };

View File

@@ -0,0 +1,126 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Toggle from 'react-toggle';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// This function activates the dropdown item.
handleActivate (e) {
const {
name,
onChange,
onClose,
options: { on },
} = this.props;
// If the escape key was pressed, we close the dropdown.
if (e.key === 'Escape' && onClose) {
onClose();
// Otherwise, we both close the dropdown and change the value.
} else if (onChange && (!e.key || e.key === 'Enter')) {
e.preventDefault(); // Prevents change in focus on click
if ((on === null || typeof on === 'undefined') && onClose) {
onClose();
}
onChange(name);
}
},
};
// The component.
export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleActivate } = this.handlers;
const {
active,
options: {
icon,
meta,
on,
text,
},
} = this.props;
const computedClass = classNames('composer--options--dropdown--content--item', {
active,
lengthy: meta,
'toggled-off': !on && on !== null && typeof on !== 'undefined',
'toggled-on': on,
'with-icon': icon,
});
// The result.
return (
<div
className={computedClass}
onClick={handleActivate}
onKeyDown={handleActivate}
role='button'
tabIndex='0'
>
{function () {
// We render a `<Toggle>` if we were provided an `on`
// property, and otherwise show an `<Icon>` if available.
switch (true) {
case on !== null && typeof on !== 'undefined':
return (
<Toggle
checked={on}
onChange={handleActivate}
/>
);
case !!icon:
return (
<Icon
className='icon'
fullwidth
icon={icon}
/>
);
default:
return null;
}
}()}
{meta ? (
<div className='content'>
<strong>{text}</strong>
{meta}
</div>
) : <div className='content'>{text}</div>}
</div>
);
}
};
// Props.
ComposerOptionsDropdownContentItem.propTypes = {
active: PropTypes.bool,
name: PropTypes.string,
onChange: PropTypes.func,
onClose: PropTypes.func,
options: PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
on: PropTypes.bool,
text: PropTypes.node,
}),
};

View File

@@ -0,0 +1,225 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Overlay from 'react-overlays/lib/Overlay';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import ComposerOptionsDropdownContent from './content';
// Utils.
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// Closes the dropdown.
handleClose () {
this.setState({ open: false });
},
// The enter key toggles the dropdown's open state, and the escape
// key closes it.
handleKeyDown ({ key }) {
const {
handleClose,
handleToggle,
} = this.handlers;
switch (key) {
case 'Enter':
handleToggle();
break;
case 'Escape':
handleClose();
break;
}
},
// Creates an action modal object.
handleMakeModal () {
const component = this;
const {
items,
onChange,
onModalOpen,
onModalClose,
value,
} = this.props;
// Required props.
if (!(onChange && onModalOpen && onModalClose && items)) {
return null;
}
// The object.
return {
actions: items.map(
({
name,
...rest
}) => ({
...rest,
active: value && name === value,
name,
onClick (e) {
e.preventDefault(); // Prevents focus from changing
onModalClose();
onChange(name);
},
onPassiveClick (e) {
e.preventDefault(); // Prevents focus from changing
onChange(name);
component.setState({ needsModalUpdate: true });
},
})
),
};
},
// Toggles opening and closing the dropdown.
handleToggle () {
const { handleMakeModal } = this.handlers;
const { onModalOpen } = this.props;
const { open } = this.state;
// If this is a touch device, we open a modal instead of the
// dropdown.
if (isUserTouching()) {
// This gets the modal to open.
const modal = handleMakeModal();
// If we can, we then open the modal.
if (modal && onModalOpen) {
onModalOpen(modal);
return;
}
}
// Otherwise, we just set our state to open.
this.setState({ open: !open });
},
// If our modal is open and our props update, we need to also update
// the modal.
handleUpdate () {
const { handleMakeModal } = this.handlers;
const { onModalOpen } = this.props;
const { needsModalUpdate } = this.state;
// Gets our modal object.
const modal = handleMakeModal();
// Reopens the modal with the new object.
if (needsModalUpdate && modal && onModalOpen) {
onModalOpen(modal);
}
},
};
// The component.
export default class ComposerOptionsDropdown extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
needsModalUpdate: false,
open: false,
};
}
// Updates our modal as necessary.
componentDidUpdate (prevProps) {
const { handleUpdate } = this.handlers;
const { items } = this.props;
const { needsModalUpdate } = this.state;
if (needsModalUpdate && items.find(
(item, i) => item.on !== prevProps.items[i].on
)) {
handleUpdate();
this.setState({ needsModalUpdate: false });
}
}
// Rendering.
render () {
const {
handleClose,
handleKeyDown,
handleToggle,
} = this.handlers;
const {
active,
disabled,
title,
icon,
items,
onChange,
value,
} = this.props;
const { open } = this.state;
const computedClass = classNames('composer--options--dropdown', {
active,
open,
});
// The result.
return (
<div
className={computedClass}
onKeyDown={handleKeyDown}
>
<IconButton
active={open || active}
className='value'
disabled={disabled}
icon={icon}
onClick={handleToggle}
size={18}
style={{
height: null,
lineHeight: '27px',
}}
title={title}
/>
<Overlay
containerPadding={20}
placement='bottom'
show={open}
target={this}
>
<ComposerOptionsDropdownContent
items={items}
onChange={onChange}
onClose={handleClose}
value={value}
/>
</Overlay>
</div>
);
}
}
// Props.
ComposerOptionsDropdown.propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
icon: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
on: PropTypes.bool,
text: PropTypes.node,
})).isRequired,
onChange: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
title: PropTypes.string,
value: PropTypes.string,
};

View File

@@ -0,0 +1,329 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from 'flavours/glitch/components/text_icon_button';
import Dropdown from './dropdown';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
advanced_options_icon_title: {
defaultMessage: 'Advanced options',
id: 'advanced_options.icon_title',
},
attach: {
defaultMessage: 'Attach...',
id: 'compose.attach',
},
change_privacy: {
defaultMessage: 'Adjust status privacy',
id: 'privacy.change',
},
direct_long: {
defaultMessage: 'Post to mentioned users only',
id: 'privacy.direct.long',
},
direct_short: {
defaultMessage: 'Direct',
id: 'privacy.direct.short',
},
doodle: {
defaultMessage: 'Draw something',
id: 'compose.attach.doodle',
},
local_only_long: {
defaultMessage: 'Do not post to other instances',
id: 'advanced-options.local-only.long',
},
local_only_short: {
defaultMessage: 'Local-only',
id: 'advanced-options.local-only.short',
},
private_long: {
defaultMessage: 'Post to followers only',
id: 'privacy.private.long',
},
private_short: {
defaultMessage: 'Followers-only',
id: 'privacy.private.short',
},
public_long: {
defaultMessage: 'Post to public timelines',
id: 'privacy.public.long',
},
public_short: {
defaultMessage: 'Public',
id: 'privacy.public.short',
},
sensitive: {
defaultMessage: 'Mark media as sensitive',
id: 'compose_form.sensitive',
},
spoiler: {
defaultMessage: 'Hide text behind warning',
id: 'compose_form.spoiler',
},
unlisted_long: {
defaultMessage: 'Do not show in public timelines',
id: 'privacy.unlisted.long',
},
unlisted_short: {
defaultMessage: 'Unlisted',
id: 'privacy.unlisted.short',
},
upload: {
defaultMessage: 'Upload a file',
id: 'compose.attach.upload',
},
});
// Handlers.
const handlers = {
// Handles file selection.
handleChangeFiles ({ target: { files } }) {
const { onUpload } = this.props;
if (files.length && onUpload) {
onUpload(files);
}
},
// Handles attachment clicks.
handleClickAttach (name) {
const { fileElement } = this;
const { onDoodleOpen } = this.props;
// We switch over the name of the option.
switch (name) {
case 'upload':
if (fileElement) {
fileElement.click();
}
return;
case 'doodle':
if (onDoodleOpen) {
onDoodleOpen();
}
return;
}
},
// Handles a ref to the file input.
handleRefFileElement (fileElement) {
this.fileElement = fileElement;
},
};
// The component.
export default class ComposerOptions extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.fileElement = null;
}
// Rendering.
render () {
const {
handleChangeFiles,
handleClickAttach,
handleRefFileElement,
} = this.handlers;
const {
acceptContentTypes,
disabled,
doNotFederate,
full,
hasMedia,
intl,
onChangeSensitivity,
onChangeVisibility,
onModalClose,
onModalOpen,
onToggleAdvancedOption,
onToggleSpoiler,
privacy,
resetFileKey,
sensitive,
spoiler,
} = this.props;
// We predefine our privacy items so that we can easily pick the
// dropdown icon later.
const privacyItems = {
direct: {
icon: 'envelope',
meta: <FormattedMessage {...messages.direct_long} />,
name: 'direct',
text: <FormattedMessage {...messages.direct_short} />,
},
private: {
icon: 'lock',
meta: <FormattedMessage {...messages.private_long} />,
name: 'private',
text: <FormattedMessage {...messages.private_short} />,
},
public: {
icon: 'globe',
meta: <FormattedMessage {...messages.public_long} />,
name: 'public',
text: <FormattedMessage {...messages.public_short} />,
},
unlisted: {
icon: 'unlock-alt',
meta: <FormattedMessage {...messages.unlisted_long} />,
name: 'unlisted',
text: <FormattedMessage {...messages.unlisted_short} />,
},
};
// The result.
return (
<div className='composer--options'>
<input
accept={acceptContentTypes}
disabled={disabled || full}
key={resetFileKey}
onChange={handleChangeFiles}
ref={handleRefFileElement}
type='file'
{...hiddenComponent}
/>
<Dropdown
disabled={disabled || full}
icon='paperclip'
items={[
{
icon: 'cloud-upload',
name: 'upload',
text: <FormattedMessage {...messages.upload} />,
},
{
icon: 'paint-brush',
name: 'doodle',
text: <FormattedMessage {...messages.doodle} />,
},
]}
onChange={handleClickAttach}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.attach)}
/>
<Motion
defaultStyle={{ scale: 0.87 }}
style={{
scale: spring(hasMedia ? 1 : 0.87, {
stiffness: 200,
damping: 3,
}),
}}
>
{({ scale }) => (
<div
style={{
display: hasMedia ? null : 'none',
transform: `scale(${scale})`,
}}
>
<IconButton
active={sensitive}
className='sensitive'
disabled={spoiler}
icon={sensitive ? 'eye-slash' : 'eye'}
inverted
onClick={onChangeSensitivity}
size={18}
style={{
height: null,
lineHeight: null,
}}
title={intl.formatMessage(messages.sensitive)}
/>
</div>
)}
</Motion>
<hr />
<Dropdown
disabled={disabled}
icon={(privacyItems[privacy] || {}).icon}
items={[
privacyItems.public,
privacyItems.unlisted,
privacyItems.private,
privacyItems.direct,
]}
onChange={onChangeVisibility}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.change_privacy)}
value={privacy}
/>
<TextIconButton
active={spoiler}
ariaControls='glitch.composer.spoiler.input'
label='CW'
onClick={onToggleSpoiler}
title={intl.formatMessage(messages.spoiler)}
/>
<Dropdown
active={doNotFederate}
disabled={disabled}
icon='home'
items={[
{
meta: <FormattedMessage {...messages.local_only_long} />,
name: 'do_not_federate',
on: doNotFederate,
text: <FormattedMessage {...messages.local_only_short} />,
},
]}
onChange={onToggleAdvancedOption}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.advanced_options_icon_title)}
/>
</div>
);
}
}
// Props.
ComposerOptions.propTypes = {
acceptContentTypes: PropTypes.string,
disabled: PropTypes.bool,
doNotFederate: PropTypes.bool,
full: PropTypes.bool,
hasMedia: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChangeSensitivity: PropTypes.func,
onChangeVisibility: PropTypes.func,
onDoodleOpen: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
onToggleAdvancedOption: PropTypes.func,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func,
privacy: PropTypes.string,
resetFileKey: PropTypes.number,
sensitive: PropTypes.bool,
spoiler: PropTypes.bool,
};

View File

@@ -0,0 +1,121 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {
defineMessages,
FormattedMessage,
} from 'react-intl';
import { length } from 'stringz';
// Components.
import Button from 'flavours/glitch/components/button';
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { maxChars } from 'flavours/glitch/util/initial_state';
// Messages.
const messages = defineMessages({
publish: {
defaultMessage: 'Toot',
id: 'compose_form.publish',
},
publishLoud: {
defaultMessage: '{publish}!',
id: 'compose_form.publish_loud',
},
});
// The component.
export default function ComposerPublisher ({
countText,
disabled,
intl,
onSecondarySubmit,
onSubmit,
privacy,
sideArm,
}) {
const diff = maxChars - length(countText || '');
const computedClass = classNames('composer--publisher', {
disabled: disabled || diff < 0,
over: diff < 0,
});
// The result.
return (
<div className={computedClass}>
<span className='count'>{diff}</span>
{sideArm && sideArm !== 'none' ? (
<Button
className='side_arm'
disabled={disabled || diff < 0}
onClick={onSecondarySubmit}
style={{ padding: null }}
text={
<span>
<Icon
icon={{
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[sideArm]}
/>
</span>
}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
/>
) : null}
<Button
className='primary'
text={function () {
switch (true) {
case !!sideArm && sideArm !== 'none':
case privacy === 'direct':
case privacy === 'private':
return (
<span>
<Icon
icon={{
direct: 'envelope',
private: 'lock',
public: 'globe',
unlisted: 'unlock-alt',
}[privacy]}
/>
<FormattedMessage {...messages.publish} />
</span>
);
case privacy === 'public':
return (
<span>
<FormattedMessage
{...messages.publishLoud}
values={{ publish: <FormattedMessage {...messages.publish} /> }}
/>
</span>
);
default:
return <span><FormattedMessage {...messages.publish} /></span>;
}
}()}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
onClick={onSubmit}
disabled={disabled || diff < 0}
/>
</div>
);
}
// Props.
ComposerPublisher.propTypes = {
countText: PropTypes.string,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onSecondarySubmit: PropTypes.func,
onSubmit: PropTypes.func,
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
};

View File

@@ -0,0 +1,113 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
// Components.
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import IconButton from 'flavours/glitch/components/icon_button';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { isRtl } from 'flavours/glitch/util/rtl';
// Messages.
const messages = defineMessages({
cancel: {
defaultMessage: 'Cancel',
id: 'reply_indicator.cancel',
},
});
// Handlers.
const handlers = {
// Handles a click on the "close" button.
handleClick () {
const { onCancel } = this.props;
if (onCancel) {
onCancel();
}
},
// Handles a click on the status's account.
handleClickAccount () {
const {
account,
history,
} = this.props;
if (history) {
history.push(`/accounts/${account.get('id')}`);
}
},
};
// The component.
export default class ComposerReply extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const {
handleClick,
handleClickAccount,
} = this.handlers;
const {
account,
content,
intl,
} = this.props;
// The result.
return (
<article className='composer--reply'>
<header>
<IconButton
className='cancel'
icon='times'
onClick={handleClick}
title={intl.formatMessage(messages.cancel)}
/>
{account ? (
<a
className='account'
href={account.get('url')}
onClick={handleClickAccount}
>
<Avatar
account={account}
className='avatar'
size={24}
/>
<DisplayName
account={account}
className='display_name'
/>
</a>
) : null}
</header>
<div
className='content'
dangerouslySetInnerHTML={{ __html: content || '' }}
style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
/>
</article>
);
}
}
ComposerReply.propTypes = {
account: ImmutablePropTypes.map,
content: PropTypes.string,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
};

View File

@@ -0,0 +1,92 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage } from 'react-intl';
// Components.
import Collapsable from 'flavours/glitch/components/collapsable';
// Utils.
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'Write your warning here',
id: 'compose_form.spoiler_placeholder',
},
});
// Handlers.
const handlers = {
// Handles a keypress.
handleKeyDown ({
ctrlKey,
keyCode,
metaKey,
}) {
const { onSubmit } = this.props;
// We submit the status on control/meta + enter.
if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
onSubmit();
}
},
};
// The component.
export default class ComposerSpoiler extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleKeyDown } = this.handlers;
const {
hidden,
intl,
onChange,
text,
} = this.props;
// The result.
return (
<Collapsable
isVisible={!hidden}
fullHeight={50}
>
<label className='composer--spoiler'>
<span {...hiddenComponent}>
<FormattedMessage {...messages.placeholder} />
</span>
<input
id='glitch.composer.spoiler.input'
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={intl.formatMessage(messages.placeholder)}
type='text'
value={text}
/>
</label>
</Collapsable>
);
}
}
// Props.
ComposerSpoiler.propTypes = {
hidden: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
onSubmit: PropTypes.func,
text: PropTypes.string,
};

View File

@@ -0,0 +1,298 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
defineMessages,
FormattedMessage,
} from 'react-intl';
import Textarea from 'react-textarea-autosize';
// Components.
import EmojiPicker from 'flavours/glitch/features/emoji_picker';
import ComposerTextareaSuggestions from './suggestions';
// Utils.
import { isRtl } from 'flavours/glitch/util/rtl';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'What is on your mind?',
id: 'compose_form.placeholder',
},
});
// Handlers.
const handlers = {
// When blurring the textarea, suggestions are hidden.
handleBlur () {
//this.setState({ suggestionsHidden: true });
},
// When the contents of the textarea change, we have to pull up new
// autosuggest suggestions if applicable, and also change the value
// of the textarea in our store.
handleChange ({
target: {
selectionStart,
value,
},
}) {
const {
onChange,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
} = this.props;
const { lastToken } = this.state;
// This gets the token at the caret location, if it begins with an
// `@` (mentions) or `:` (shortcodes).
const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
const right = value.slice(selectionStart).search(/[\s\u200B]/);
const token = function () {
switch (true) {
case left < 0 || /[@:]/.test(!value[left]):
return null;
case right < 0:
return value.slice(left);
default:
return value.slice(left, right + selectionStart).trim().toLowerCase();
}
}();
// We only request suggestions for tokens which are at least 3
// characters long.
if (onSuggestionsFetchRequested && token && token.length >= 3) {
if (lastToken !== token) {
this.setState({
lastToken: token,
selectedSuggestion: 0,
tokenStart: left,
});
onSuggestionsFetchRequested(token);
}
} else {
this.setState({ lastToken: null });
if (onSuggestionsClearRequested) {
onSuggestionsClearRequested();
}
}
// Updates the value of the textarea.
if (onChange) {
onChange(value);
}
},
// Handles a click on an autosuggestion.
handleClickSuggestion (index) {
const { textarea } = this;
const {
onSuggestionSelected,
suggestions,
} = this.props;
const {
lastToken,
tokenStart,
} = this.state;
onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
textarea.focus();
},
// Handles a keypress. If the autosuggestions are visible, we need
// to allow keypresses to navigate and sleect them.
handleKeyDown (e) {
const {
disabled,
onSubmit,
onSuggestionSelected,
suggestions,
} = this.props;
const {
lastToken,
suggestionsHidden,
selectedSuggestion,
tokenStart,
} = this.state;
// Keypresses do nothing if the composer is disabled.
if (disabled) {
e.preventDefault();
return;
}
// Switches over the pressed key.
switch(e.key) {
// On arrow down, we pick the next suggestion.
case 'ArrowDown':
if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
return;
// On arrow up, we pick the previous suggestion.
case 'ArrowUp':
if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
return;
// On enter or tab, we select the suggestion.
case 'Enter':
case 'Tab':
if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
}
return;
}
// We submit the status on control/meta + enter.
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
onSubmit();
}
},
// When the escape key is released, we either close the suggestions
// window or focus the UI.
handleKeyUp ({ key }) {
const { suggestionsHidden } = this.state;
if (key === 'Escape') {
if (!suggestionsHidden) {
this.setState({ suggestionsHidden: true });
} else {
document.querySelector('.ui').parentElement.focus();
}
}
},
// Handles the pasting of images into the composer.
handlePaste (e) {
const { onPaste } = this.props;
let d;
if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
onPaste(d);
e.preventDefault();
}
},
// Saves a reference to the textarea.
handleRefTextarea (textarea) {
this.textarea = textarea;
},
};
// The component.
export default class ComposerTextarea extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
// Instance variables.
this.textarea = null;
}
// When we receive new suggestions, we unhide the suggestions window
// if we didn't have any suggestions before.
componentWillReceiveProps (nextProps) {
const { suggestions } = this.props;
const { suggestionsHidden } = this.state;
if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleClickSuggestion,
handleKeyDown,
handleKeyUp,
handlePaste,
handleRefTextarea,
} = this.handlers;
const {
autoFocus,
disabled,
intl,
onPickEmoji,
suggestions,
value,
} = this.props;
const {
selectedSuggestion,
suggestionsHidden,
} = this.state;
// The result.
return (
<div className='composer--textarea'>
<label>
<span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
<Textarea
aria-autocomplete='list'
autoFocus={autoFocus}
className='textarea'
disabled={disabled}
inputRef={handleRefTextarea}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
/>
</label>
<EmojiPicker onPickEmoji={onPickEmoji} />
<ComposerTextareaSuggestions
hidden={suggestionsHidden}
onSuggestionClick={handleClickSuggestion}
suggestions={suggestions}
value={selectedSuggestion}
/>
</div>
);
}
}
// Props.
ComposerTextarea.propTypes = {
autoFocus: PropTypes.bool,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
onPaste: PropTypes.func,
onPickEmoji: PropTypes.func,
onSubmit: PropTypes.func,
onSuggestionsClearRequested: PropTypes.func,
onSuggestionsFetchRequested: PropTypes.func,
onSuggestionSelected: PropTypes.func,
suggestions: ImmutablePropTypes.list,
value: PropTypes.string,
};
// Default props.
ComposerTextarea.defaultProps = { autoFocus: true };

View File

@@ -0,0 +1,43 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Components.
import ComposerTextareaSuggestionsItem from './item';
// The component.
export default function ComposerTextareaSuggestions ({
hidden,
onSuggestionClick,
suggestions,
value,
}) {
// The result.
return (
<div
className='composer--textarea--suggestions'
hidden={hidden || !suggestions || suggestions.isEmpty()}
>
{!hidden && suggestions ? suggestions.map(
(suggestion, index) => (
<ComposerTextareaSuggestionsItem
index={index}
key={typeof suggestion === 'object' ? suggestion.id : suggestion}
onClick={onSuggestionClick}
selected={index === value}
suggestion={suggestion}
/>
)
) : null}
</div>
);
}
ComposerTextareaSuggestions.propTypes = {
hidden: PropTypes.bool,
onSuggestionClick: PropTypes.func,
suggestions: ImmutablePropTypes.list,
value: PropTypes.number,
};

View File

@@ -0,0 +1,101 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
// Utils.
import { unicodeMapping } from 'flavours/glitch/util/emoji';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Gets our asset host from the environment, if available.
const assetHost = ((process || {}).env || {}).CDN_HOST || '';
// Handlers.
const handlers = {
// Handles a click on a suggestion.
handleClick (e) {
const {
index,
onClick,
} = this.props;
if (onClick) {
e.preventDefault();
onClick(index);
}
},
};
// The component.
export default class ComposerTextareaSuggestionsItem extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleClick } = this.handlers;
const {
selected,
suggestion,
} = this.props;
const computedClass = classNames('composer--textarea--suggestions--item', { selected });
// The result.
return (
<div
className={computedClass}
onMouseDown={handleClick}
role='button'
tabIndex='0'
>
{ // If the suggestion is an object, then we render an emoji.
// Otherwise, we render an account.
typeof suggestion === 'object' ? function () {
const url = function () {
if (suggestion.custom) {
return suggestion.imageUrl;
} else {
const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
return `${assetHost}/emoji/${mapping.filename}.svg`;
}
}();
return url ? (
<div className='emoji'>
<img
alt={suggestion.native || suggestion.colons}
className='emojione'
src={url}
/>
{suggestion.colons}
</div>
) : null;
}() : (
<AccountContainer
id={suggestion}
small
/>
)
}
</div>
);
}
}
// Props.
ComposerTextareaSuggestionsItem.propTypes = {
index: PropTypes.number,
onClick: PropTypes.func,
selected: PropTypes.bool,
suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
};

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