Compare commits

...

276 commits

Author SHA1 Message Date
Izalia Mae 679ff766f8 Merge branch 'main' of https://git.barkshark.xyz/mirror/mastodon 2024-02-26 19:19:12 -05:00
Claire 502e3f8c19
Merge pull request #2648 from TheEssem/fix/thread-typo
Fix "threaded more" typo
2024-02-26 12:07:02 +01:00
Essem fa2bbbfd9d
Fix "threaded more" typo 2024-02-25 17:10:25 -06:00
Claire 78c92c0a01
Merge pull request #2647 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to cfa71a4d16
2024-02-24 17:51:00 +01:00
Claire 3ffb81e04f [Glitch] Add end-to-end test for OCR in media uploads
Port ca8fbda5d0 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 16:13:13 +01:00
Claire ad0be125f5 [Glitch] Fix pixel alignment for some composer icons
Port 25ffe0af45 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 15:53:42 +01:00
Claire 2dc244784c Merge commit 'cfa71a4d16e71b04a42dda564ed8e188bd1badd9' into glitch-soc/merge-upstream 2024-02-24 15:43:15 +01:00
Claire 1ad91dece8 Merge commit '9d8dfeb5fbbc274482489a3ac9f22dd736da156c' into glitch-soc/merge-upstream
Conflicts:
- `app/javascript/packs/admin.jsx`:
  Changes applied to `app/javascript/core/admin.js` instead.
2024-02-24 15:35:56 +01:00
Claire 7901dc9e24 Merge commit '491dd9764244c8adf37861f00d916c96bdbfdaf8' into glitch-soc/merge-upstream
Conflicts:
- `app/workers/scheduler/auto_close_registrations_scheduler.rb`:
  Changes were already cherry-picked and updated further in glitch-soc.
  Kept glitch-soc's version.
2024-02-24 15:33:36 +01:00
Claire d2cfc6e5e2 Merge commit '08342ad40c1b92caf873282190efe8533a7d6e2e' into glitch-soc/merge-upstream 2024-02-24 15:02:28 +01:00
Hinaloe ba67ea3d12 [Glitch] Fix sensitive flag not being removed when removing CW in new compose form
Port c645490d55 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:53:49 +01:00
Eugen Rochko 200e11ae88 [Glitch] Change follow suggestions design in web UI
Port 63f4ea055a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:53:09 +01:00
Claire 26924a0c7d [Glitch] Change source attribute of Suggestion entity in /api/v2/suggestions back to a string
Port 7ee93b7431 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:52:44 +01:00
Nicolas Hoffmann e45f40d203 [Glitch] Fix modal container bounds
Port 476a043fc5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:49:35 +01:00
Claire 9f0ff2bedf [Glitch] Clean up some unused CSS definitions
Port 67ec192d7d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:49:03 +01:00
Claire ef3d15554b Merge commit 'c645490d553124d800d30488595f7d2d9d61584d' into glitch-soc/merge-upstream
Conflicts:
- `Gemfile.lock`:
  Changes were already cherry-picked and updated further in glitch-soc.
  Kept glitch-soc's version.
- `README.md`:
  Upstream updated its README, we have a completely different one.
  Kept glitch-soc's README.
- `app/models/account.rb`:
  Not a real conflict, upstream updated some lines textually adjacent
  to glitch-soc-specific lines.
  Ported upstream's changes.
2024-02-24 14:46:14 +01:00
Claire 3c36e1be68 [Glitch] Fix report reason selector in moderation interface not unselecting rules when changing category
Port 9ce914cc89 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:36:55 +01:00
y.takahashi 3894674200 [Glitch] Fix 'focus the compose textarea' shortcut is not working
Port 3c315a68af to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:35:52 +01:00
Eugen Rochko cce3f3d6da [Glitch] Change onboarding prompt to follow suggestions carousel in web UI
Port 9cdc60ecc6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:35:08 +01:00
Claire ab2f0daa10 Merge commit 'aaa58d4807377e04649499ebee91757b16b9a007' into glitch-soc/merge-upstream
Conflicts:
- `.github/workflows/build-security.yml`:
  Changes were already cherry-picked and adapted in glitch-soc.
  Kept glitch-soc's version.
- `Gemfile.lock`:
  Changes were already cherry-picked and updated further in glitch-soc.
  Kept glitch-soc's version.
- `lib/mastodon/version.rb`:
  Changes were already cherry-picked and updated further in glitch-soc.
  Kept glitch-soc's version.
2024-02-24 14:27:43 +01:00
Yamagishi Kazutoshi b4cca47f5f [Glitch] Remove unused l18n messages
Port b3075a9993 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:19:26 +01:00
Claire 5b9ddfcfcc Merge commit 'fa0ba677538588086d83c97c0ea56f9cd1556590' into glitch-soc/merge-upstream 2024-02-24 14:18:16 +01:00
J H 4d6844eb2f [Glitch] Fixed the toggle emoji dropdown bug
Port 1467f1e1e1 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 14:15:59 +01:00
Claire dfd74f0dae Merge commit '1467f1e1e1c18dc4b310862ff1f719165a24cfb6' into glitch-soc/merge-upstream 2024-02-24 14:15:49 +01:00
Claire 73de36318e Move api/v1/timelines/direct to request spec 2024-02-24 14:10:05 +01:00
Claire 9903e6beab Merge commit '0b0ca6f3b85c9d08e4642e49d743f8d060632293' into glitch-soc/merge-upstream
Conflicts:
- `spec/controllers/api/v1/timelines/direct_controller_spec.rb`:
  `spec/controllers/api/v1/timelines` has been renamed, but we had an extra
  spec here for a glitch-soc-only endpoint.
  Kept glitch-soc's file unchanged (will port to a request spec later).
2024-02-24 14:05:26 +01:00
Claire c297d999ba Merge commit '87ad398ddc78f2da5746774960690661e8e57335' into glitch-soc/merge-upstream 2024-02-24 14:02:01 +01:00
Claire 47b5105e5d Remove max_toot_chars from initial-state
This is still in `/api/v1/instance` for compatibility with other clients
2024-02-24 13:58:00 +01:00
Claire 6da69967d0 [Glitch] Change compose form to use server-provided post character limit
Port 805dba7f8d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-24 13:56:12 +01:00
Claire 20615a516d Merge commit '805dba7f8d2a2d5f910ec1396247b36417170345' into glitch-soc/merge-upstream 2024-02-24 13:53:59 +01:00
Claire 25ac55ecd8
Merge pull request #2644 from ClearlyClaire/glitch-soc/merge-dreaded-upstream-composer-change
Merge and port upstream's composer change
2024-02-24 13:46:26 +01:00
Claire 6901930c8d Add back confirmation modal for missing media description 2024-02-23 23:04:32 +01:00
Claire 138286147f Fix emoji being inserted in incorrect places 2024-02-23 21:45:48 +01:00
Claire 6eede9d84b Add notification badge feature back 2024-02-23 21:05:34 +01:00
Claire 9ab030bb13 Add thread mode button 2024-02-23 21:05:34 +01:00
Claire e333453343 Use Warning icon for CWs in app settings 2024-02-23 21:05:34 +01:00
Claire e62cd93650 Fix composer offering to edit federation settings for an existing post 2024-02-23 21:05:34 +01:00
Claire 23dc650596 Restore preselect on reply option 2024-02-23 21:05:34 +01:00
Claire 84d05ca221 Reimplement glitchy elephant friend 2024-02-23 21:05:33 +01:00
Claire 3564a15553 Refactor composer dropdowns 2024-02-23 21:05:33 +01:00
Claire 0e77c45624 Add local-only option back as a federation setting dropdown 2024-02-23 21:05:33 +01:00
Claire 47deb680d5 Add Content-Type dropdown back 2024-02-23 21:05:33 +01:00
Claire 118bb5bc81 Add secondary post button back 2024-02-23 21:05:33 +01:00
Claire 61559a42a9 Restore glitch-soc's permalink behavior for reply indicator 2024-02-23 20:57:20 +01:00
Claire 179437ed0e Restore access to glitch-soc app settings 2024-02-23 20:57:20 +01:00
Claire 056f9bf3c2 Add back “spoilers always on” feature 2024-02-23 20:57:18 +01:00
Claire cfa71a4d16
Fix admin account created by mastodon:setup not being auto-approved (#29379) 2024-02-23 19:04:57 +00:00
Claire 25ffe0af45
Fix pixel alignment for some composer icons (#29372) 2024-02-23 15:32:13 +00:00
github-actions[bot] bba4118ddd
New Crowdin Translations (automated) (#29369)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-23 15:21:02 +00:00
Matt Jankowski b0064ddda7
Add basic coverage for MoveService class (#29301) 2024-02-23 09:59:29 +00:00
renovate[bot] baa23738a8
Update dependency webmock to v3.22.0 (#29313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-23 09:57:26 +00:00
Claire 380f4fc95c Reimplement sensitive checkbox 2024-02-22 23:49:06 +01:00
Claire 5fd50756b4 Restore glitch-soc's support of custom poll limits 2024-02-22 23:06:12 +01:00
Eugen Rochko 9c10aaa4d5 [Glitch] Change design of compose form in web UI
Port 6936e5aa69 to glitch-soc

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-22 23:06:12 +01:00
Matt Jankowski 45f71e3954
Update @rails/ujs to version 7.1.3-2 (#29359) 2024-02-22 21:35:14 +00:00
renovate[bot] 82e2370f5f
Update dependency cssnano to v6.0.4 (#29367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 21:34:08 +00:00
Claire 9d8dfeb5fb
Fix processing of Link objects in Image objects (#29335) 2024-02-22 22:27:24 +01:00
Matt Jankowski a6ed148769
Use heredoc on the HTML blocks in verify link spec (#29365) 2024-02-22 21:26:48 +00:00
Claire 7586d4348f Switch glitch-soc to upstream's old composer 2024-02-22 22:10:17 +01:00
Claire 10a0d76bf0 Merge commit '6936e5aa693ccc4aabd26ef18a65fbb8132f6f74' into glitch-soc/merge-upstream-composer
Conflicts:
- `app/javascript/mastodon/features/compose/components/compose_form.jsx`:
  Upstream completely redesigned this, and glitch-soc had different handling for
  the character limit.
  Ported upstream's change to the new version.
- `app/javascript/mastodon/features/compose/components/poll_form.jsx`:
  Upstream completely redesigned this, and glitch-soc had different limits for
  option length and number of options.
  Ported glitch-soc's changes to the new version.

Additional change:
- `app/javascript/styles/components.scss`:
  Change how the new image is looked up.
2024-02-22 20:58:41 +01:00
Claire f25414014a Fix link verifications when page size exceeds 1MB (#29358) 2024-02-22 20:45:40 +01:00
Claire b0822ae9cd Fix auto-close email being sent to users with devops permissions instead of settings permissions (#29355) 2024-02-22 20:45:40 +01:00
Claire d23f445527 Automatically switch from open to approved registrations in absence of moderators (#29318) 2024-02-22 20:45:40 +01:00
Wojciech Maj d908ed8e86 Remove unused dependencies (#29289) 2024-02-22 20:45:40 +01:00
renovate[bot] 3dae23816b Update dependency haml_lint to v0.57.0 (#29181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 20:45:40 +01:00
renovate[bot] d10848946c Update dependency rails to v7.1.3.2 (#29342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 20:45:40 +01:00
renovate[bot] fec49022ed Update dependency selenium-webdriver to v4.18.1 (#29287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 20:45:40 +01:00
renovate[bot] 840ce1e0cb Update dependency webmock to v3.21.2 (#29290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 20:45:40 +01:00
renovate[bot] 7cd5866ddc Update dependency doorkeeper to v5.6.9 (#29196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 20:45:40 +01:00
Claire fff378177e
Further reduce differences with upstream (mainly private mention styling) (#2641)
* Use upstream's CSS for private mentions styling

* Further reduce differences with upstream
2024-02-22 18:45:54 +01:00
Claire 5152dd869e
Fix link verifications when page size exceeds 1MB (#29358) 2024-02-22 17:31:50 +00:00
Claire a9496882fc
Fix auto-close email being sent to users with devops permissions instead of settings permissions (#29355) 2024-02-22 14:52:14 +00:00
Claire b71904816a
Change registrations to be disabled by default for new servers (#29280) 2024-02-22 13:28:19 +00:00
Emelia Smith 491dd97642
Streaming: refactor to custom Error classes (#28632)
Co-authored-by: Renaud Chaput <renchap@gmail.com>
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-02-22 13:20:20 +00:00
renovate[bot] ebe2086087
Update dependency haml_lint to v0.57.0 (#29181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 11:26:48 +00:00
Matt Jankowski e9b0f0c314
Add basic coverage for RemoveDomainsFromFollowersService class (#29327) 2024-02-22 10:53:38 +00:00
Matt Jankowski 7c7dfe7de3
Add basic coverage for RemoveFeaturedTagService class (#29328) 2024-02-22 10:51:04 +00:00
Matt Jankowski 6342ddd698
Add basic coverage for UnfavouriteService class (#29329) 2024-02-22 10:48:42 +00:00
Matt Jankowski f70905f127
Add basic coverage for UnmuteService class (#29330) 2024-02-22 10:48:09 +00:00
Matt Jankowski a69fe534e3
Add basic coverage for WebhookService class (#29331) 2024-02-22 10:46:20 +00:00
Matt Jankowski d1602c017d
Add basic coverage for ApproveAppealService class (#29333) 2024-02-22 10:40:07 +00:00
Matt Jankowski ab2ef63a03
Add basic coverage for VoteService class (#29334) 2024-02-22 10:39:18 +00:00
renovate[bot] fa1c3a0915
Update dependency rack to v2.2.8.1 (#29341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 10:37:54 +00:00
renovate[bot] 96d5c44db2
Update dependency rails to v7.1.3.2 (#29342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 10:37:52 +00:00
github-actions[bot] 449fcf1ae7
New Crowdin Translations (automated) (#29343)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-22 10:37:48 +00:00
renovate[bot] 53a4648db1
Update dependency pino to v8.19.0 (#29253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 09:03:13 +00:00
Matt Jankowski 006aa4e35a
Update husky and remove deprecated features (#29338) 2024-02-22 09:02:15 +00:00
renovate[bot] c63567a214
Update typescript-eslint monorepo to v7 (major) (#29179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 22:06:17 +00:00
renovate[bot] 638861b2a3
Update dependency http-link-header to v1.1.2 (#29340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 21:31:09 +00:00
renovate[bot] aed60df80e
Update dependency sass to v1.71.1 (#29238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 21:29:41 +00:00
renovate[bot] bcc0860100
Update dependency core-js to v3.36.0 (#29197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 21:28:53 +00:00
renovate[bot] df4ed60331
Update dependency dotenv to v16.4.5 (#29190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 21:28:10 +00:00
renovate[bot] 38d0292281
Update dependency @reduxjs/toolkit to v2.2.1 (#29178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 21:27:28 +00:00
renovate[bot] d48d824d9a
Update DefinitelyTyped types (non-major) (#29165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 21:27:00 +00:00
Matt Jankowski 581f14e56f
Update yarn to version 4.1.0 (#29339) 2024-02-21 21:18:01 +00:00
Claire 604c93332f
Fix pillbar button colors (#2639) 2024-02-21 19:30:54 +01:00
Claire 2751acb6cd
Automatically switch from open to approved registrations in absence of moderators (#29318) 2024-02-21 17:45:06 +00:00
Matt Jankowski 08342ad40c
Add basic coverage for AfterUnallowDomainService class (#29324) 2024-02-21 17:13:11 +00:00
Matt Jankowski 8f61e32569
Add basic coverage for AppealService class (#29322) 2024-02-21 17:12:31 +00:00
Matt Jankowski b73932461f
Add basic coverage for CreateFeaturedTagService class (#29321) 2024-02-21 16:58:19 +00:00
Matt Jankowski 5f19e7e799
Add basic coverage for ProcessHashtagsService class (#29320) 2024-02-21 16:57:45 +00:00
Claire 154a3119fb
Further reduce CSS differences with upstream (#2638) 2024-02-21 17:50:41 +01:00
Matt Jankowski 1f648fdf1a
Remove erroneous service type on TagFeed model spec (#29302) 2024-02-21 11:25:33 +00:00
github-actions[bot] fd2b6c29c6
New Crowdin Translations (automated) (#29311)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-21 11:24:44 +00:00
Claire bf6e57b420
Further reduce CSS and markup differences with upstream (#2637)
* Reduce differences in `MovedNote` markup and styling

* Remove unused setting toggle meta text support

* Fix various CSS discrepancies with upstream

* Further reduce differences with upstream
2024-02-20 23:06:17 +01:00
Emelia Smith 4ad7fadb82
Fix logging error in streaming server (#2636)
Logger changed upstream causing `log.silly` to error and crash the server for local only statuses
2024-02-20 20:19:58 +01:00
Claire 5f50b634cf
Further reduce CSS and markup differences with upstream (#2635)
* Further reduce CSS differences with upstream

* Reduce differences in markup and CSS with upstream

* Redo collapsible post notifications

* Reduce CSS differences further

* Reduce differences with upstream regarding `.status` and `.status__wrapper`

* Further reduce differences with upstream

* Reduce differences with upstream in DisplayName
2024-02-20 18:49:59 +01:00
Wojciech Maj 36fd47ac57
Remove unused dependencies (#29289) 2024-02-20 10:10:58 +00:00
Matt Jankowski 937dad1ee6
Extract ES query and filter hashes into private methods in TagSearchService (#29288) 2024-02-20 10:08:32 +00:00
github-actions[bot] 9a2b9d1484
New Crowdin Translations (automated) (#29298)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-20 09:23:40 +00:00
Matt Jankowski 0ef44ee720
Move AccountSuggestions::Source subclasses default limit value to constant (#29282) 2024-02-20 09:21:49 +00:00
renovate[bot] e1cee84e58
Update dependency cocoon-js-vanilla to v1.4.0 (#29293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 09:19:41 +00:00
Claire bde4df6be6
Remove CSS definitions for some unused classes (#29279) 2024-02-20 09:18:44 +00:00
Matt Jankowski 785e2f9399
Add scope providing_styles to UserRole (#29286) 2024-02-20 09:18:05 +00:00
renovate[bot] 5d9d0d174a
Update dependency selenium-webdriver to v4.18.1 (#29287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 09:17:41 +00:00
renovate[bot] 6e4c1e172b
Update dependency webmock to v3.21.2 (#29290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 09:11:22 +00:00
Eugen Rochko b8b2f20b16
Change explore icon from hashtag to compass in web UI (#29294) 2024-02-20 09:10:44 +00:00
Claire 0a2b95c4f5
Adopt upstream's design for preview cards (#2634)
Co-authored-by: Eugen Rochko <eugen@zeonfederated.com>
Co-authored-by: Christian Schmidt <github@chsc.dk>
2024-02-19 22:47:01 +01:00
Claire 89ea492d70
Further reduce CSS changes with upstream (#2632) 2024-02-19 19:54:51 +01:00
Matt Jankowski 64f9939e39
Use capture_emails helper to improve email assertions in specs (#29245) 2024-02-19 15:57:47 +00:00
Wolfgang Fournès 86627ea2e4
Add a missing thread example to the statuses spec (#29278) 2024-02-19 13:35:58 +00:00
Matt Jankowski 72b0c9e20b
Re-enable fixed Style/Semicolon cop (#29212) 2024-02-19 13:35:13 +00:00
Hinaloe c645490d55
Fix sensitive flag not being removed when removing CW in new compose form (#29248) 2024-02-19 13:31:16 +00:00
github-actions[bot] ad16362efe
New Crowdin Translations (automated) (#29255)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-19 13:25:45 +00:00
renovate[bot] fe6c055f35
Update dependency eslint-plugin-jsdoc to v48.1.0 (#29275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-19 11:10:03 +00:00
Eugen Rochko 63f4ea055a
Change follow suggestions design in web UI (#29272) 2024-02-19 11:09:58 +00:00
Matt Jankowski 245064bb98
Move "everyone" role and "instance actor" account magic number IDs to constants (#29260) 2024-02-19 11:09:43 +00:00
Claire bbecf1c56b
Further reduce differences with upstream (#2631)
* Further reduce CSS diffs with upstream

* Remove superfluous “Sensitive content” label in media galleries and clean up dead code
2024-02-18 22:30:20 +01:00
Claire 034be5ad50
Further reduce CSS differences with upstream (#2630) 2024-02-18 20:45:36 +01:00
Claire 9a11b077f7
Reduce CSS and markup differences with upstream (#2629) 2024-02-18 19:12:23 +01:00
Danko Aleksejevs e078ede097
Remove status content from mobile actions modal (#2615) 2024-02-18 19:10:58 +01:00
Claire 8a10feb8dc [Glitch] Fix Web UI not displaying appropriate explanation when a user hides their follows/followers
Port 9b06c0f24a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-17 20:15:25 +01:00
Claire d86b642dc6
Fix dead and misapplied CSS (#2627) 2024-02-17 18:52:59 +01:00
Claire 41c85e3b6d
Change list title input design to fit upstream (#2626)
* Change settings input field design to match upstream's

* Fix light theme discrepancies

* Restore regexp inputs as glitch-soc-specific styles
2024-02-17 18:52:37 +01:00
Claire 5c0a64a975
Merge pull request #2625 from ClearlyClaire/glitch-soc/ports/copy-button-on-profile
Add prominent share/copy button on profiles in web UI
2024-02-17 09:39:46 +01:00
Claire 50d9cd66d3 [Glitch] Use container queries to hide profile share button
Port c94bedf4e6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-16 18:18:17 +01:00
Eugen Rochko ce3978246b [Glitch] Add prominent share/copy button on profiles in web UI
Port 87696ea26e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2024-02-16 18:17:55 +01:00
Claire c0279385d7
Adopt upstream's design for account profiles (#2622)
* Mostly adopt upstream's design for account profiles

* Reduce some margins
2024-02-16 17:57:43 +01:00
Claire 96ddf1d482
Fix flaky end-to-end OCR test (#29244) 2024-02-16 16:57:23 +00:00
Wolfgang Fournès cfadb87077
Update enum syntax to use the new Rails 7.0 style (#29217) 2024-02-16 14:54:23 +00:00
Matt Jankowski 1d9d14b8de
Use abort instead of warn(); exit in boot.rb env check (#29209) 2024-02-16 14:29:24 +00:00
Matt Jankowski 1946e171e6
Reduce round trips in admin/disputes/appeals spec (#29234) 2024-02-16 13:46:28 +00:00
Matt Jankowski 3454fcbd71
Reduce round trips in auth/sessions spec (#29233) 2024-02-16 13:38:49 +00:00
Matt Jankowski a316c0e38d
Reduce round trips in disputes/appeals spec (#29232) 2024-02-16 13:01:15 +00:00
Matt Jankowski 117b507df5
Extract subject from User#mark_email_as_confirmed! spec (#29231) 2024-02-16 13:01:04 +00:00
Matt Jankowski 1690fb39e6
Reduce RSpec/MultipleExpectations in instance_actors_controller spec (#29229) 2024-02-16 13:00:11 +00:00
Matt Jankowski bba488c189
Reduce RSpec/MultipleExpectations in media_attachment spec (#29228) 2024-02-16 13:00:09 +00:00
renovate[bot] e140d05a6a
Update dependency doorkeeper to v5.6.9 (#29196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-16 12:59:51 +00:00
Claire 5f21a1f5a3
Bump version to v4.3.0-alpha.3 (#29241) 2024-02-16 11:06:47 +00:00
Claire 9fee5e8526
Merge pull request from GHSA-jhrq-qvrm-qr36
* Fix insufficient Content-Type checking of fetched ActivityStreams objects

* Allow JSON-LD documents with multiple profiles
2024-02-16 11:56:12 +01:00
renovate[bot] a8ee6fdd62
Update dependency pg to v1.5.5 (#29230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-16 08:12:10 +00:00
Matt Jankowski a9f9b0097b
Reduce RSpec/MultipleExpectations in captcha feature spec (#29226) 2024-02-16 07:52:57 +00:00
Matt Jankowski 4b7f04e3ea
Reduce RSpec/MultipleExpectations in post_status_service spec (#29225) 2024-02-16 07:52:29 +00:00
github-actions[bot] 1c93d625c6
New Crowdin Translations (automated) (#29195)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-16 07:51:13 +00:00
Matt Jankowski ed4939296a
Reduce RSpec/MultipleExpectations in ap/activity/create spec (#29224) 2024-02-16 07:43:00 +00:00
Matt Jankowski 1df2ffc3ee
Use subject in blacklist email validator spec (#29211) 2024-02-16 07:42:03 +00:00
Matt Jankowski fc4f823464
Avoid local block var assignment in ap/process_status_update_service spec (#29210) 2024-02-16 07:41:25 +00:00
Claire d4d0565b0f
Fix user creation failure handling in OAuth paths (#29207) 2024-02-14 21:49:45 +00:00
Matt Jankowski 844aa59bdf
Doc update about ruby version 3.0+ (#29202) 2024-02-14 16:44:27 +00:00
Claire bbbbf00084
Fix OmniAuth tests (#29201) 2024-02-14 14:57:49 +00:00
Claire 8e8e0f104f
Bump version to v4.3.0-alpha.2 (#29200) 2024-02-14 14:20:02 +00:00
Claire b31af34c97
Merge pull request from GHSA-vm39-j3vx-pch3
* Prevent different identities from a same SSO provider from accessing a same account

* Lock auth provider changes behind `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH=true`

* Rename methods to avoid confusion between OAuth and OmniAuth
2024-02-14 15:16:07 +01:00
Emelia Smith 68eaa804c9
Merge pull request from GHSA-7w3c-p9j8-mq3x
* Ensure destruction of OAuth Applications notifies streaming

Due to doorkeeper using a dependent: delete_all relationship, the destroy of an OAuth Application bypassed the existing AccessTokenExtension callbacks for announcing destructing of access tokens.

* Ensure password resets revoke access to Streaming API

* Improve performance of deleting OAuth tokens

---------

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-02-14 15:15:34 +01:00
Claire 554e2a019e
Add sidekiq_unique_jobs:delete_all_locks task and disable sidekiq-unique-jobs UI by default (#29199) 2024-02-14 12:12:13 +00:00
Emelia Smith 46142cdbdd
Disable administrative doorkeeper routes (#29187) 2024-02-13 18:11:47 +00:00
Emelia Smith e8b66a0525
Ignore legacy moderator and admin columns on User model (#29188) 2024-02-13 17:14:49 +00:00
Nicolas Hoffmann 476a043fc5
Fix modal container bounds (#29185) 2024-02-13 12:58:21 +00:00
github-actions[bot] 5de1ce23c3
New Crowdin Translations (automated) (#29182)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-13 09:08:55 +00:00
renovate[bot] e25d9dfb25
Update dependency dotenv to v16.4.3 (#29174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-13 08:02:55 +00:00
renovate[bot] b244e5cb81
Update dependency sidekiq-unique-jobs to v7.1.33 (#29175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-13 07:56:46 +00:00
renovate[bot] 819ede190e
Update dependency dotenv to v16.4.2 (#29157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-12 10:39:31 +00:00
renovate[bot] cf0d6bef8b
Update eslint (non-major) (#29166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-12 10:21:17 +00:00
renovate[bot] 58918844a9
Update peter-evans/create-pull-request action to v6 (#29167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-12 10:20:54 +00:00
github-actions[bot] 6482948547
New Crowdin Translations (automated) (#29156)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-12 09:59:05 +00:00
Claire 8125dae5a8
Rename ES_CA_CERT to ES_CA_FILE for consistency (#29147) 2024-02-12 09:54:06 +00:00
github-actions[bot] c07028b2fa
New Crowdin Translations (automated) (#29152)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-09 09:55:30 +00:00
Claire ca8fbda5d0
Add end-to-end test for OCR in media uploads (#29148) 2024-02-08 19:13:44 +00:00
Matt Jankowski a9e91eb955
Add common stub setup for resolv dns in email mx validator spec (#29140) 2024-02-08 14:26:45 +00:00
Claire 67ec192d7d
Clean up some unused CSS definitions (#29146) 2024-02-08 11:40:22 +00:00
renovate[bot] d4db1c497b
Update dependency pghero to v3.4.1 (#29144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-08 10:03:39 +00:00
renovate[bot] 13840d685c
Update dependency irb to v1.11.2 (#29135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-08 10:03:30 +00:00
Matt Jankowski 15437e4ad9
Add context and before to lengthy tag manager spec examples (#29129) 2024-02-08 10:03:04 +00:00
Matt Jankowski 5271131658
Extract helper method for repeated form fill in admin/domain_blocks feature spec (#29128) 2024-02-08 10:02:53 +00:00
renovate[bot] 52986f35b8
Update dependency postcss to v8.4.35 (#29136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-08 09:49:24 +00:00
github-actions[bot] 2a362d62a8
New Crowdin Translations (automated) (#29145)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-08 09:43:02 +00:00
Claire eff447a455
Rewrite signature verification using regexps and StringScanner (#29133) 2024-02-07 17:24:42 +00:00
Matt Jankowski 79b4b94f3a
Configure CountAsOne value for RSpec/ExampleLength cop (#29115) 2024-02-07 14:54:40 +00:00
Matt Jankowski 95da28d201
Add common ThreadingHelper module for specs (#29116) 2024-02-07 14:53:29 +00:00
renovate[bot] dbafec88e5
Update dependency @reduxjs/toolkit to v2.1.0 (#28879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-07 14:10:38 +00:00
Matt Jankowski b2133fee5f
Update artifact preservation to use save_path value (#29035) 2024-02-07 13:37:10 +00:00
Claire 7efc33b909
Move HTTP Signature parsing code to its own class (#28932) 2024-02-07 13:35:37 +00:00
github-actions[bot] 17052714a2
New Crowdin Translations (automated) (#29121)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-07 12:38:37 +00:00
Claire 2912829411
Add support for specifying custom CA cert for Elasticsearch (#29122) 2024-02-07 12:09:43 +00:00
Claire eeabf9af72
Fix compatibility with Redis <6.2 (#29123) 2024-02-07 11:52:38 +00:00
Matt Jankowski da50217b88
Combine repeated requests in admin/accounts controller spec (#29119) 2024-02-07 10:59:32 +00:00
renovate[bot] 492e25da06
Update dependency webmock to v3.20.0 (#29120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-07 10:56:52 +00:00
Claire aaa58d4807
Fix tagging of manual security nightly builds (#29061) 2024-02-06 20:48:11 +00:00
github-actions[bot] 90ccf7beb2
New Crowdin Translations (automated) (#28965)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-02-06 18:47:44 +00:00
Claire 7ee93b7431
Change source attribute of Suggestion entity in /api/v2/suggestions back to a string (#29108) 2024-02-06 17:10:17 +00:00
Matt Jankowski 1e0b0a3486
Use SQL heredoc on long statement lines in migrations (#29112) 2024-02-06 16:42:25 +00:00
Claire 64300e0fe3
Fix self-destruct schedule not actually replacing initial schedule (#29049) 2024-02-06 15:32:09 +00:00
Matt Jankowski 2f19ddd1fa
Move status serializer error handling to private method (#29031) 2024-02-06 14:54:26 +00:00
Matt Jankowski 0df86d77fd
Reduce RSpec/ExampleLength in PostStatusService spec example (#29105) 2024-02-06 14:36:04 +00:00
Matt Jankowski 93a5b3f9df
Move status serializer chooser to private method (#29030) 2024-02-06 13:33:42 +00:00
renovate[bot] cf42eba0f9
Update dependency brakeman to v6.1.2 (#29062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 13:31:54 +00:00
Matt Jankowski 69e61fff38
Move direct serializer usage out of admin view partial (#29028) 2024-02-06 13:18:37 +00:00
Matt Jankowski 2d6ab44556
Reduce request/response round-trips in ap/collections controller spec (#29102) 2024-02-06 13:10:00 +00:00
Matt Jankowski 978fdc71ca
Reduce expectation count in example from ProcessAccountService spec (#29100) 2024-02-06 13:04:02 +00:00
Matt Jankowski 0877f6fda4
Remove redundant return in IntentsController (#29099) 2024-02-06 12:56:22 +00:00
renovate[bot] e8cc98977d
Update dependency bootsnap to '~> 1.18.0' (#29019)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 12:52:28 +00:00
Matt Jankowski 577520b637
Replace deprecated Sidekiq::Testing block style (#29097) 2024-02-06 12:49:48 +00:00
Matt Jankowski df7acdcee5
Update markers API spec for error case (#29096) 2024-02-06 12:47:04 +00:00
Emelia Smith 4fb7f611de
Return domain block digests from admin domain blocks API (#29092) 2024-02-06 12:38:14 +00:00
Matt Jankowski dedefdc303
Move length value mapping to constant in ids to bigints migration (#29048) 2024-02-06 11:40:24 +00:00
Matt Jankowski 4cf07ed78c
Add missing action logging to api/v1/admin/reports#update (#29044) 2024-02-06 11:34:11 +00:00
renovate[bot] a31427a629
Update dependency pino to v8.18.0 (#29043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 11:33:06 +00:00
Matt Jankowski 0bec5c0755
Remove migration base class switcher from RailsSettingsMigration (#29047) 2024-02-06 09:56:24 +00:00
renovate[bot] 62028b1b1b
Update libretranslate/libretranslate Docker tag to v1.5.5 (#29090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 09:38:32 +00:00
renovate[bot] 90f4b8d53a
Update dependency postcss to v8.4.34 (#29103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 09:37:03 +00:00
Claire 9ce914cc89
Fix report reason selector in moderation interface not unselecting rules when changing category (#29026) 2024-02-06 09:35:36 +00:00
Claire 66dda7c762
Fix already-invalid reports failing to resolve (#29027) 2024-02-06 09:35:27 +00:00
Matt Jankowski 779d987fbc
Update codedoc/codecov-action to v4 (#29036) 2024-02-06 09:34:28 +00:00
renovate[bot] a2d8aa1583
Update dependency tzinfo-data to v1.2024.1 (#29052)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 09:33:41 +00:00
Matt Jankowski 5ca45403e2
Update nsa gem to version 0.3.0 (#29065) 2024-02-06 09:33:11 +00:00
renovate[bot] 586b4faf61
Update dependency haml_lint to v0.56.0 (#29082)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 09:24:44 +00:00
renovate[bot] 916aeb574d
Update DefinitelyTyped types (non-major) (#29088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 09:23:55 +00:00
renovate[bot] f00ba02653
Update dependency nokogiri to v1.16.2 [SECURITY] (#29106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-06 09:20:23 +00:00
Matt Jankowski 86500e3312
Extract scenic view model common methods to concern (#28111) 2024-02-06 09:08:07 +00:00
Eugen Rochko 1666b19559
Fix confirmation e-mails when signing up through an app (#29064) 2024-02-02 15:51:26 +00:00
y.takahashi 3c315a68af
Fix 'focus the compose textarea' shortcut is not working (#29059) 2024-02-02 06:33:53 +00:00
Claire 1726085db5
Merge pull request from GHSA-3fjr-858r-92rw
* Fix insufficient origin validation

* Bump version to 4.3.0-alpha.1
2024-02-01 15:56:46 +01:00
Eugen Rochko 9cdc60ecc6
Change onboarding prompt to follow suggestions carousel in web UI (#28878) 2024-02-01 13:37:04 +00:00
Claire 7316a08380
Fix missing workflow_dispatch trigger for build-security (#29041) 2024-02-01 09:52:01 +00:00
Matt Jankowski 8b7b0ee598
Configure selenium to use Chrome version 120 (#29038) 2024-02-01 09:46:31 +00:00
Claire 812a131423
Add github action workflow for manual security builds (#29040) 2024-02-01 09:33:12 +00:00
Matt Jankowski dd934ebb07
Update actions/cache to v4 (updates node 16->20) (#29025) 2024-01-31 16:55:50 +00:00
renovate[bot] 738dba0cf7
Update dependency capybara to v3.40.0 (#28966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-31 12:55:15 +00:00
Matt Jankowski 022d2a3793
Make factory gems available in test+development envs (#28969) 2024-01-31 12:52:51 +00:00
Eugen Rochko fa0ba67753
Change materialized views to be refreshed concurrently to avoid locks (#29015) 2024-01-30 18:21:30 +00:00
Eugen Rochko c4af668e5c
Fix follow recommendations for less used languages (#29017) 2024-01-30 17:24:40 +00:00
renovate[bot] 0c0d077276
Update dependency chewy to v7.5.1 (#29018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-30 15:40:43 +00:00
renovate[bot] 0bc526a967
Update eslint (non-major) (#29001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-30 15:39:59 +00:00
renovate[bot] 8c08e5cdb2
Update devDependencies (non-major) (#29000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-30 15:39:34 +00:00
Matt Jankowski ce0d134147
Add redirect_with_vary to AllowedMethods for Style/FormatStringToken cop (#28973) 2024-01-30 15:39:01 +00:00
Matt Jankowski 86fbde7b46
Fix Style/NumericLiterals cop in ProfileStories support module (#28971) 2024-01-30 15:38:33 +00:00
Yamagishi Kazutoshi b3075a9993
Remove unused l18n messages (#28964) 2024-01-30 15:34:07 +00:00
Matt Jankowski f91acba70a
Combine repeated requests in account controller concern spec (#28957) 2024-01-30 15:32:20 +00:00
renovate[bot] 9d3830344f
Update dependency immutable to v4.3.5 (#28933)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-30 15:30:39 +00:00
Matt Jankowski adcd693b71
Use existing MediaAttachment.remote scope in media CLI (#28912) 2024-01-30 15:29:42 +00:00
J H 1467f1e1e1
Fixed the toggle emoji dropdown bug (#29012) 2024-01-30 13:38:49 +00:00
Matt Jankowski ff8937aa2c
Move api/v1/statuses/* to request spec (#28954) 2024-01-26 17:45:54 +00:00
Matt Jankowski 44f6d285af
Combine repeated subject in ap fetch remote actor service spec (#28953) 2024-01-26 17:44:12 +00:00
Matt Jankowski 239244e2ed
Combine repeated subject in ap fetch remote account service spec (#28952) 2024-01-26 17:43:08 +00:00
Matt Jankowski 5119fbc9b7
Move api/v1/admin/trends/links/preview_card_providers to request spec (#28951) 2024-01-26 17:41:39 +00:00
Matt Jankowski b6baab447d
Move api/v2/admin/accounts to request spec (#28950) 2024-01-26 17:41:13 +00:00
Matt Jankowski 7adcc0aae3
Move api/v1/trends/* to request specs (#28949) 2024-01-26 17:40:39 +00:00
Matt Jankowski 0b0ca6f3b8
Move api/v1/timelines/list to request spec (#28948) 2024-01-26 17:40:15 +00:00
renovate[bot] 87ad398ddc
Update formatjs monorepo (#28925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-26 16:49:16 +00:00
renovate[bot] f4a12adfb7
Update dependency axios to v1.6.7 (#28917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-26 16:49:09 +00:00
renovate[bot] 160c8f4923
Update babel monorepo to v7.23.9 (#28916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-26 16:49:03 +00:00
Matt Jankowski e519f113e8
Combine repeated subject in cacheable response shared example (#28945) 2024-01-26 16:37:05 +00:00
Matt Jankowski d791bca11b
Combine double subject in well_known/webfinger shared example (#28944) 2024-01-26 16:36:21 +00:00
Matt Jankowski 09a3493fca
Combine double subject in api/v1/media shared example (#28943) 2024-01-26 16:35:49 +00:00
Matt Jankowski 5fbdb2055b
Combine repeated subject in cli/accounts spec shared example (#28942) 2024-01-26 16:35:19 +00:00
Matt Jankowski 1a30a517d6
Combine repeated subjects in link details extractor spec (#28941) 2024-01-26 16:31:07 +00:00
Matt Jankowski 685eaa04d4
Combine double subject in admin/statuses controller shared example (#28940) 2024-01-26 16:30:30 +00:00
Matt Jankowski beb74fd71c
Combine double subjects in instance actors controller shared example (#28939) 2024-01-26 16:28:50 +00:00
Matt Jankowski beaef4b672
Combine double subjects in application controller shared example (#28938) 2024-01-26 16:23:12 +00:00
Matt Jankowski 6d35a77c92
Combine repeated subjects in models/user spec (#28937) 2024-01-26 16:22:44 +00:00
Matt Jankowski 2f8656334d
Combine double subjects in admin/accounts controller spec (#28936) 2024-01-26 16:21:31 +00:00
Matt Jankowski 9cc1817bb4
Fix intmermittent failure in api/v1/accounts/statuses controller spec (#28931) 2024-01-26 14:10:26 +00:00
Claire 805dba7f8d
Change compose form to use server-provided post character limit (#28928) 2024-01-26 14:09:45 +00:00
github-actions[bot] 45287049ab
New Crowdin Translations (automated) (#28923)
Co-authored-by: GitHub Actions <noreply@github.com>
2024-01-26 09:15:55 +00:00
Matt Jankowski 0e0a94f483
Handle CLI failure exit status at the top-level script (#28322) 2024-01-26 08:53:44 +00:00
Emelia Smith 881e8c113c
Refactor: fix streaming postgresql and redis typing issues (#28747) 2024-01-25 16:46:02 +00:00
Eugen Rochko 6936e5aa69
Change design of compose form in web UI (#28119)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-01-25 15:41:31 +00:00
649 changed files with 14412 additions and 12878 deletions

View file

@ -70,7 +70,7 @@ services:
hard: -1
libretranslate:
image: libretranslate/libretranslate:v1.5.4
image: libretranslate/libretranslate:v1.5.5
restart: unless-stopped
volumes:
- lt-data:/home/libretranslate/.local

View file

@ -165,7 +165,7 @@ module.exports = defineConfig({
// },
// ],
'jsx-a11y/no-noninteractive-tabindex': 'off',
'jsx-a11y/no-onchange': 'warn',
'jsx-a11y/no-onchange': 'off',
// recommended is full 'error'
'jsx-a11y/no-static-element-interactions': [
'warn',

View file

@ -23,7 +23,7 @@ runs:
shell: bash
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
- uses: actions/cache@v4
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}

View file

@ -53,7 +53,7 @@ jobs:
# Create or update the pull request
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5.0.2
uses: peter-evans/create-pull-request@v6.0.0
with:
commit-message: 'New Crowdin translations'
title: 'New Crowdin Translations (automated)'

View file

@ -139,7 +139,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
files: coverage/lcov/mastodon.lcov
@ -224,7 +224,7 @@ jobs:
if: failure()
with:
name: e2e-screenshots
path: tmp/screenshots/
path: tmp/capybara/
test-search:
name: Elastic Search integration testing
@ -328,4 +328,4 @@ jobs:
if: failure()
with:
name: test-search-screenshots
path: tmp/screenshots/
path: tmp/capybara/

View file

@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged

View file

@ -96,13 +96,6 @@ Rails/FilePath:
Rails/HttpStatus:
EnforcedStyle: numeric
# Reason: Allowed in `tootctl` CLI code and in boot ENV checker
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsexit
Rails/Exit:
Exclude:
- 'config/boot.rb'
- 'lib/mastodon/cli/*.rb'
# Reason: Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railslexicallyscopedactionfilter
Rails/LexicallyScopedActionFilter:
@ -135,6 +128,11 @@ Rails/UnusedIgnoredColumns:
Rails/NegateInclude:
Enabled: false
# Reason: Enforce default limit, but allow some elements to span lines
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecexamplelength
RSpec/ExampleLength:
CountAsOne: ['array', 'heredoc', 'method_call']
# Reason: Deprecated cop, will be removed in 3.0, replaced by SpecFilePathFormat
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath
RSpec/FilePath:
@ -175,6 +173,15 @@ Style/ClassAndModuleChildren:
Style/Documentation:
Enabled: false
# Reason: Route redirects are not token-formatted and must be skipped
# https://docs.rubocop.org/rubocop/cops_style.html#styleformatstringtoken
Style/FormatStringToken:
inherit_mode:
merge:
- AllowedMethods # The rubocop-rails config adds `redirect`
AllowedMethods:
- redirect_with_vary
# Reason: Enforce modern Ruby style
# https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax
Style/HashSyntax:
@ -203,11 +210,6 @@ Style/RedundantBegin:
Style/RescueStandardError:
EnforcedStyle: implicit
# Reason: Simplify some spec layouts
# https://docs.rubocop.org/rubocop/cops_style.html#stylesemicolon
Style/Semicolon:
AllowAsExpressionSeparator: true
# Reason: Originally disabled for CodeClimate, and no config consensus has been found
# https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray
Style/SymbolArray:

View file

@ -36,10 +36,10 @@ Metrics/PerceivedComplexity:
# Configuration parameters: CountAsOne.
RSpec/ExampleLength:
Max: 22
Max: 20 # Override default of 5
RSpec/MultipleExpectations:
Max: 8
Max: 7
# Configuration parameters: AllowSubject.
RSpec/MultipleMemoizedHelpers:

12
Gemfile
View file

@ -125,12 +125,6 @@ group :test do
# Used to mock environment variables
gem 'climate_control'
# Generating fake data for specs
gem 'faker', '~> 3.2'
# Generate test objects for specs
gem 'fabrication', '~> 2.30'
# Add back helpers functions removed in Rails 5.1
gem 'rails-controller-testing', '~> 1.0'
@ -182,6 +176,12 @@ group :development, :test do
# Interactive Debugging tools
gem 'debug', '~> 1.8'
# Generate fake data values
gem 'faker', '~> 3.2'
# Generate factory objects
gem 'fabrication', '~> 2.30'
# Profiling tools
gem 'memory_profiler', require: false
gem 'ruby-prof', require: false

View file

@ -10,35 +10,35 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
actioncable (7.1.3.2)
actionpack (= 7.1.3.2)
activesupport (= 7.1.3.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
actionmailbox (7.1.3.2)
actionpack (= 7.1.3.2)
activejob (= 7.1.3.2)
activerecord (= 7.1.3.2)
activestorage (= 7.1.3.2)
activesupport (= 7.1.3.2)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.3)
actionpack (= 7.1.3)
actionview (= 7.1.3)
activejob (= 7.1.3)
activesupport (= 7.1.3)
actionmailer (7.1.3.2)
actionpack (= 7.1.3.2)
actionview (= 7.1.3.2)
activejob (= 7.1.3.2)
activesupport (= 7.1.3.2)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.1.3)
actionview (= 7.1.3)
activesupport (= 7.1.3)
actionpack (7.1.3.2)
actionview (= 7.1.3.2)
activesupport (= 7.1.3.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@ -46,15 +46,15 @@ GEM
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.3)
actionpack (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
actiontext (7.1.3.2)
actionpack (= 7.1.3.2)
activerecord (= 7.1.3.2)
activestorage (= 7.1.3.2)
activesupport (= 7.1.3.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3)
activesupport (= 7.1.3)
actionview (7.1.3.2)
activesupport (= 7.1.3.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@ -64,22 +64,22 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.1.3)
activesupport (= 7.1.3)
activejob (7.1.3.2)
activesupport (= 7.1.3.2)
globalid (>= 0.3.6)
activemodel (7.1.3)
activesupport (= 7.1.3)
activerecord (7.1.3)
activemodel (= 7.1.3)
activesupport (= 7.1.3)
activemodel (7.1.3.2)
activesupport (= 7.1.3.2)
activerecord (7.1.3.2)
activemodel (= 7.1.3.2)
activesupport (= 7.1.3.2)
timeout (>= 0.4.0)
activestorage (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activesupport (= 7.1.3)
activestorage (7.1.3.2)
actionpack (= 7.1.3.2)
activejob (= 7.1.3.2)
activerecord (= 7.1.3.2)
activesupport (= 7.1.3.2)
marcel (~> 1.0)
activesupport (7.1.3)
activesupport (7.1.3.2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
@ -219,7 +219,7 @@ GEM
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.6.8)
doorkeeper (5.6.9)
railties (>= 5)
dotenv (2.8.1)
dotenv-rails (2.8.1)
@ -309,7 +309,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.56.0)
haml_lint (0.57.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -444,7 +444,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.9.1)
net-imap (0.4.10)
date
net-protocol
net-ldap (0.19.0)
@ -532,7 +532,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.3)
rack (2.2.8)
rack (2.2.8.1)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.1)
@ -554,20 +554,20 @@ GEM
rackup (1.0.0)
rack (< 3)
webrick
rails (7.1.3)
actioncable (= 7.1.3)
actionmailbox (= 7.1.3)
actionmailer (= 7.1.3)
actionpack (= 7.1.3)
actiontext (= 7.1.3)
actionview (= 7.1.3)
activejob (= 7.1.3)
activemodel (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
rails (7.1.3.2)
actioncable (= 7.1.3.2)
actionmailbox (= 7.1.3.2)
actionmailer (= 7.1.3.2)
actionpack (= 7.1.3.2)
actiontext (= 7.1.3.2)
actionview (= 7.1.3.2)
activejob (= 7.1.3.2)
activemodel (= 7.1.3.2)
activerecord (= 7.1.3.2)
activestorage (= 7.1.3.2)
activesupport (= 7.1.3.2)
bundler (>= 1.15.0)
railties (= 7.1.3)
railties (= 7.1.3.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@ -582,9 +582,9 @@ GEM
rails-i18n (7.0.8)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
railties (7.1.3.2)
actionpack (= 7.1.3.2)
activesupport (= 7.1.3.2)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@ -691,7 +691,7 @@ GEM
scenic (1.7.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
selenium-webdriver (4.17.0)
selenium-webdriver (4.18.1)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
@ -793,7 +793,7 @@ GEM
webfinger (1.2.0)
activesupport
httpclient (>= 2.4)
webmock (3.20.0)
webmock (3.22.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@ -811,7 +811,7 @@ GEM
xorcist (1.1.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.12)
zeitwerk (2.6.13)
PLATFORMS
ruby

View file

@ -62,11 +62,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' || signed_request_account.nil?
# Re-using the syntax for signature parameters
tree = SignatureParamsParser.new.parse(raw_params)
params = SignatureParamsTransformer.new.apply(tree)
params = SignatureParser.parse(raw_params)
ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
rescue Parslet::ParseFailed
rescue SignatureParser::ParsingError
Rails.logger.warn 'Error parsing Collection-Synchronization header'
end

View file

@ -35,6 +35,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController
def update
authorize @report, :update?
@report.update!(report_params)
log_action :update, @report
render json: @report, serializer: REST::Admin::ReportSerializer
end

View file

@ -72,13 +72,9 @@ class Api::V1::StatusesController < Api::BaseController
with_rate_limit: true
)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
render json: @status, serializer: serializer_for_status
rescue PostStatusService::UnexpectedMentionsError => e
unexpected_accounts = ActiveModel::Serializer::CollectionSerializer.new(
e.accounts,
serializer: REST::AccountSerializer
)
render json: { error: e.message, unexpected_accounts: unexpected_accounts }, status: 422
render json: unexpected_accounts_error_json(e), status: 422
end
def update
@ -158,6 +154,21 @@ class Api::V1::StatusesController < Api::BaseController
)
end
def serializer_for_status
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
def unexpected_accounts_error_json(error)
{
error: error.message,
unexpected_accounts: serialized_accounts(error.accounts),
}
end
def serialized_accounts(accounts)
ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end

View file

@ -188,7 +188,9 @@ class Auth::SessionsController < Devise::SessionsController
)
# Only send a notification email every hour at most
return if redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
return if redis.get("2fa_failure_notification:#{user.id}").present?
redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour)
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end

View file

@ -12,39 +12,6 @@ module SignatureVerification
class SignatureVerificationError < StandardError; end
class SignatureParamsParser < Parslet::Parser
rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
# qdtext and quoted_pair are not exactly according to spec but meh
rule(:qdtext) { match('[^\\\\"]') }
rule(:quoted_pair) { str('\\') >> any }
rule(:bws) { match('\s').repeat }
rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
rule(:comma) { bws >> str(',') >> bws }
# Old versions of node-http-signature add an incorrect "Signature " prefix to the header
rule(:buggy_prefix) { str('Signature ') }
rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
root(:params)
end
class SignatureParamsTransformer < Parslet::Transform
rule(params: subtree(:param)) do
(param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value }
end
rule(param: { key: simple(:key), value: simple(:val) }) do
[key, val]
end
rule(quoted_string: simple(:string)) do
string.to_s
end
rule(token: simple(:string)) do
string.to_s
end
end
def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end
@ -135,12 +102,8 @@ module SignatureVerification
end
def signature_params
@signature_params ||= begin
raw_signature = request.headers['Signature']
tree = SignatureParamsParser.new.parse(raw_signature)
SignatureParamsTransformer.new.apply(tree)
end
rescue Parslet::ParseFailed
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
rescue SignatureParser::ParsingError
raise SignatureVerificationError, 'Error parsing signature parameters'
end

View file

@ -16,6 +16,6 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/Appli
helper_method :custom_css_styles
def set_user_roles
@user_roles = UserRole.where(highlighted: true).where.not(color: [nil, ''])
@user_roles = UserRole.providing_styles
end
end

View file

@ -1,27 +1,26 @@
# frozen_string_literal: true
class IntentsController < ApplicationController
before_action :check_uri
EXPECTED_SCHEME = 'web+mastodon'
before_action :handle_invalid_uri, unless: :valid_uri?
rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri
def show
if uri.scheme == 'web+mastodon'
case uri.host
when 'follow'
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
when 'share'
return redirect_to share_path(text: uri.query_values['text'])
end
case uri.host
when 'follow'
redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
when 'share'
redirect_to share_path(text: uri.query_values['text'])
else
handle_invalid_uri
end
not_found
end
private
def check_uri
not_found if uri.blank?
def valid_uri?
uri.present? && uri.scheme == EXPECTED_SCHEME
end
def handle_invalid_uri

View file

@ -15,9 +15,20 @@ module ReactComponentHelper
div_tag_with_data(data)
end
def serialized_media_attachments(media_attachments)
media_attachments.map { |attachment| serialized_attachment(attachment) }
end
private
def div_tag_with_data(data)
content_tag(:div, nil, data: data)
end
def serialized_attachment(attachment)
ActiveModelSerializers::SerializableResource.new(
attachment,
serializer: REST::MediaAttachmentSerializer
).as_json
end
end

View file

@ -144,6 +144,10 @@ Rails.delegate(document, '#form_admin_settings_enable_bootstrap_timeline_account
const onChangeRegistrationMode = (target) => {
const enabled = target.value === 'approved';
[].forEach.call(document.querySelectorAll('.form_admin_settings_registrations_mode .warning-hint'), (warning_hint) => {
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
});
[].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
input.disabled = !enabled;
if (enabled) {

View file

@ -21,7 +21,6 @@ let fetchComposeSuggestionsAccountsController;
let fetchComposeSuggestionsTagsController;
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
@ -59,7 +58,7 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
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_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
@ -117,12 +116,6 @@ export function changeCompose(text) {
};
}
export function cycleElefriendCompose() {
return {
type: COMPOSE_CYCLE_ELEFRIEND,
};
}
export function replyCompose(status, routerHistory) {
return (dispatch, getState) => {
const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']);
@ -148,13 +141,13 @@ export function resetCompose() {
};
}
export const focusCompose = (routerHistory, defaultText) => dispatch => {
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
});
ensureComposeIsVisible(routerHistory);
ensureComposeIsVisible(getState, routerHistory);
};
export function mentionCompose(account, routerHistory) {
@ -179,7 +172,7 @@ export function directCompose(account, routerHistory) {
};
}
export function submitCompose(routerHistory) {
export function submitCompose(routerHistory, overridePrivacy = null) {
return function (dispatch, getState) {
let status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
@ -228,7 +221,7 @@ export function submitCompose(routerHistory) {
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
spoiler_text: spoilerText,
visibility: getState().getIn(['compose', 'privacy']),
visibility: overridePrivacy || getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
},
@ -246,11 +239,6 @@ export function submitCompose(routerHistory) {
dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data }));
// If the response has no data then we can't do anything else.
if (!response.data) {
return;
}
// To make the app more responsive, immediately push the status
// into the columns
const insertIfOnline = timelineId => {
@ -660,15 +648,19 @@ export const readyComposeSuggestionsTags = (token, tags) => ({
export function selectComposeSuggestion(position, token, suggestion, path) {
return (dispatch, getState) => {
let completion;
let completion, startPosition;
if (suggestion.type === 'emoji') {
completion = suggestion.native || suggestion.colons;
completion = suggestion.native || suggestion.colons;
startPosition = position - 1;
dispatch(useEmoji(suggestion));
} else if (suggestion.type === 'hashtag') {
completion = `#${suggestion.name}`;
completion = `#${suggestion.name}`;
startPosition = position - 1;
} else if (suggestion.type === 'account') {
completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
startPosition = position;
}
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
@ -676,7 +668,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position,
position: startPosition,
token,
completion,
path,
@ -684,7 +676,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
} else {
dispatch({
type: COMPOSE_SUGGESTION_IGNORE,
position,
position: startPosition,
token,
completion,
path,
@ -786,18 +778,26 @@ export function changeComposeVisibility(value) {
};
}
export function changeComposeContentType(value) {
return {
type: COMPOSE_CONTENT_TYPE_CHANGE,
value,
};
}
export function insertEmojiCompose(position, emoji) {
export function insertEmojiCompose(position, emoji, needsSpace) {
return {
type: COMPOSE_EMOJI_INSERT,
position,
emoji,
needsSpace,
};
}
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
value,
};
}
export function changeComposeContentType(value) {
return {
type: COMPOSE_CONTENT_TYPE_CHANGE,
value,
};
}

View file

@ -54,12 +54,5 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
id: accountId,
});
api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v2/suggestions').then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
}).catch(error => dispatch(fetchSuggestionsFail(error)));
}).catch(() => {});
api(getState).delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
};

View file

@ -22,6 +22,10 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT';
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null;
export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING,
@ -123,9 +127,19 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) {
const now = new Date();
const fittingIndex = response.data.findIndex(status => now - (new Date(status.created_at)) > 4 * 3600 * 1000);
if (fittingIndex !== -1) {
dispatch(insertIntoTimeline(timelineId, TIMELINE_SUGGESTIONS, Math.max(1, fittingIndex)));
}
}
if (timelineId === 'home') {
dispatch(submitMarkers());
}
@ -233,3 +247,10 @@ export const markAsPartial = timeline => ({
type: TIMELINE_MARK_AS_PARTIAL,
timeline,
});
export const insertIntoTimeline = (timeline, key, index) => ({
type: TIMELINE_INSERT,
timeline,
index,
key,
});

View file

@ -43,4 +43,5 @@ export interface ApiAccountJSON {
suspended?: boolean;
limited?: boolean;
memorial?: boolean;
hide_collections: boolean;
}

View file

@ -37,10 +37,10 @@ class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
onFollow: PropTypes.func,
onBlock: PropTypes.func,
onMute: PropTypes.func,
onMuteNotifications: PropTypes.func,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
minimal: PropTypes.bool,
@ -147,7 +147,7 @@ class Account extends ImmutablePureComponent {
</div>
<div className='account__contents'>
<DisplayName account={account} inline />
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
{account.get('followers_count') !== -1 && (

View file

@ -124,7 +124,7 @@ class ReportReasonSelector extends PureComponent {
api().put(`/api/v1/admin/reports/${id}`, {
category,
rule_ids,
rule_ids: category === 'violation' ? rule_ids : [],
}).catch(err => {
console.error(err);
});

View file

@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
alt={emoji.native || emoji.colons}
/>
{emoji.colons}
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div>
);
}

View file

@ -1,5 +1,3 @@
import { FormattedMessage } from 'react-intl';
import { ShortNumber } from 'flavours/glitch/components/short_number';
interface Props {
@ -16,27 +14,18 @@ interface Props {
};
}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
);
};
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
</div>
)}
</div>
);

View file

@ -5,6 +5,8 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
@ -13,8 +15,8 @@ import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
@ -29,7 +31,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left, word];
return [left + 1, word];
} else {
return [null, null];
}
@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
return (
<div className='autosuggest-input'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<input
type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
<input
type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
)}
</Overlay>
</div>
);
}

View file

@ -5,6 +5,7 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Overlay from 'react-overlays/Overlay';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
@ -15,8 +16,8 @@ import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
@ -31,7 +32,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left, word];
return [left + 1, word];
} else {
return [null, null];
}
@ -52,7 +53,6 @@ const AutosuggestTextarea = forwardRef(({
onFocus,
autoFocus = true,
lang,
children,
}, textareaRef) => {
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -183,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({
);
};
return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return (
<div className='autosuggest-textarea'>
<Textarea
ref={textareaRef}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
lang={lang}
/>
<Textarea
ref={textareaRef}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(renderSuggestion)}
</div>
</div>,
];
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
</div>
</div>
)}
</Overlay>
</div>
);
});
AutosuggestTextarea.propTypes = {
@ -232,7 +230,6 @@ AutosuggestTextarea.propTypes = {
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
onFocus:PropTypes.func,
children: PropTypes.node,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};

View file

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
import { IconButton } from './icon_button';
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
});
export const CollapseButton = ({ collapsed, setCollapsed }) => {
const intl = useIntl();
const handleCollapsedClick = useCallback((e) => {
if (e.button === 0) {
setCollapsed(!collapsed);
e.preventDefault();
}
}, [collapsed, setCollapsed]);
return (
<IconButton
className='status__collapse-button'
animate
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
iconComponent={ExpandLessIcon}
onClick={handleCollapsedClick}
/>
);
};
CollapseButton.propTypes = {
collapsed: PropTypes.bool,
setCollapsed: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import { useState, useCallback } from 'react';
import { defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { showAlert } from 'flavours/glitch/actions/alerts';
import { IconButton } from 'flavours/glitch/components/icon_button';
const messages = defineMessages({
copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' },
});
export const CopyIconButton = ({ title, value, className }) => {
const [copied, setCopied] = useState(false);
const dispatch = useDispatch();
const handleClick = useCallback(() => {
navigator.clipboard.writeText(value);
setCopied(true);
dispatch(showAlert({ message: messages.copied }));
setTimeout(() => setCopied(false), 700);
}, [setCopied, value, dispatch]);
return (
<IconButton
className={classNames(className, copied ? 'copied' : 'copyable')}
title={title}
onClick={handleClick}
iconComponent={ContentCopyIcon}
/>
);
};
CopyIconButton.propTypes = {
title: PropTypes.string,
value: PropTypes.string,
className: PropTypes.string,
};

View file

@ -1,7 +1,5 @@
import React from 'react';
import classNames from 'classnames';
import type { List } from 'immutable';
import type { Account } from 'flavours/glitch/models/account';
@ -14,7 +12,6 @@ interface Props {
account?: Account;
others?: List<Account>;
localDomain?: string;
inline?: boolean;
}
export class DisplayName extends React.PureComponent<Props> {
@ -51,7 +48,7 @@ export class DisplayName extends React.PureComponent<Props> {
};
render() {
const { others, localDomain, inline } = this.props;
const { others, localDomain } = this.props;
let displayName: React.ReactNode,
suffix: React.ReactNode,
@ -114,13 +111,11 @@ export class DisplayName extends React.PureComponent<Props> {
return (
<span
className={classNames('display-name', { inline })}
className='display-name'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
{displayName}
{inline ? ' ' : null}
{suffix}
{displayName} {suffix}
</span>
);
}

View file

@ -165,7 +165,7 @@ class Dropdown extends PureComponent {
children: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
loading: PropTypes.bool,
size: PropTypes.number,
title: PropTypes.string,

View file

@ -18,26 +18,7 @@ import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
import { IconButton } from './icon_button';
const messages = defineMessages({
hidden: {
defaultMessage: 'Media hidden',
id: 'status.media_hidden',
},
sensitive: {
defaultMessage: 'Sensitive',
id: 'media_gallery.sensitive',
},
toggle: {
defaultMessage: 'Click to view',
id: 'status.sensitive_toggle',
},
toggle_visible: {
defaultMessage: '{number, plural, one {Hide image} other {Hide images}}',
id: 'media_gallery.toggle_visible',
},
warning: {
defaultMessage: 'Sensitive content',
id: 'status.sensitive_warning',
},
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
});
class Item extends PureComponent {
@ -299,8 +280,8 @@ class MediaGallery extends PureComponent {
this.props.onOpenMedia(this.props.media, index, this.props.lang);
};
handleRef = (node) => {
this.node = node;
handleRef = c => {
this.node = c;
if (this.node) {
this._setDimensions();
@ -379,11 +360,6 @@ class MediaGallery extends PureComponent {
<div className={computedClass} style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
{visible && sensitive && (
<span className='sensitive-marker'>
<FormattedMessage {...messages.sensitive} />
</span>
)}
</div>
{children}

View file

@ -23,7 +23,7 @@ export default class SettingText extends PureComponent {
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
className='glitch-setting-text'
value={settings.getIn(settingPath)}
onChange={this.handleChange}
placeholder={label}

View file

@ -23,6 +23,7 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
import { displayMedia } from '../initial_state';
import AttachmentList from './attachment_list';
import { CollapseButton } from './collapse_button';
import { getHashtagBarForStatus } from './hashtag_bar';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
@ -510,7 +511,6 @@ class Status extends ImmutablePureComponent {
render () {
const {
handleRef,
parseClick,
setCollapsed,
} = this;
@ -729,7 +729,6 @@ class Status extends ImmutablePureComponent {
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
sensitive={status.get('sensitive')}
/>,
);
@ -764,7 +763,13 @@ class Status extends ImmutablePureComponent {
account={account}
parseClick={parseClick}
notificationId={this.props.notificationId}
/>
>
{muted && settings.getIn(['collapsed', 'enabled']) && (
<div className='notification__message-collapse-button'>
<CollapseButton collapsed={isCollapsed} setCollapsed={setCollapsed} />
</div>
)}
</StatusPrepend>
);
}
@ -772,85 +777,77 @@ class Status extends ImmutablePureComponent {
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
}
const computedClass = classNames('status', `status-${status.get('visibility')}`, {
collapsed: isCollapsed,
'has-background': isCollapsed && background,
'status__wrapper-reply': !!status.get('in_reply_to_id'),
'status--in-thread': !!rootId,
'status--first-in-thread': previousId && (!connectUp || connectToRoot),
unread,
muted,
}, 'focusable');
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
contentMedia.push(hashtagBar);
return (
<HotKeys handlers={handlers}>
<div
className={computedClass}
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
className={classNames('status__wrapper', 'focusable', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, collapsed: isCollapsed })}
{...selectorAttribs}
ref={handleRef}
tabIndex={0}
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
ref={this.handleRef}
data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
>
{!muted && prepend}
{prepend}
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
<div
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background, collapsed: isCollapsed })}
data-id={status.get('id')}
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
<header className='status__info'>
<span>
{muted && prepend}
{!muted || !isCollapsed ? (
{(!muted || !isCollapsed) && (
<header className='status__info'>
<StatusHeader
status={status}
friend={account}
collapsed={isCollapsed}
parseClick={parseClick}
/>
) : null}
</span>
<StatusIcons
<StatusIcons
status={status}
mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
collapsible={!muted && settings.getIn(['collapsed', 'enabled'])}
collapsed={isCollapsed}
setCollapsed={setCollapsed}
settings={settings.get('status_icons')}
/>
</header>
)}
<StatusContent
status={status}
mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isCollapsed}
setCollapsed={setCollapsed}
settings={settings.get('status_icons')}
media={contentMedia}
extraMedia={extraMedia}
mediaIcons={contentMediaIcons}
expanded={isExpanded}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
parseClick={parseClick}
disabled={!history}
tagLinks={settings.get('tag_misleading_links')}
rewriteMentions={settings.get('rewrite_mentions')}
{...statusContentProps}
/>
</header>
<StatusContent
status={status}
media={contentMedia}
extraMedia={extraMedia}
mediaIcons={contentMediaIcons}
expanded={isExpanded}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
parseClick={parseClick}
disabled={!history}
tagLinks={settings.get('tag_misleading_links')}
rewriteMentions={settings.get('rewrite_mentions')}
{...statusContentProps}
/>
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
<StatusActionBar
status={status}
account={status.get('account')}
showReplyCount={settings.get('show_reply_count')}
onFilter={matchedFilters ? this.handleFilterClick : null}
{...other}
/>
) : null}
{notification ? (
<NotificationOverlayContainer
notification={notification}
/>
) : null}
{(!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar']))) && (
<StatusActionBar
status={status}
account={status.get('account')}
showReplyCount={settings.get('show_reply_count')}
onFilter={matchedFilters ? this.handleFilterClick : null}
{...other}
/>
)}
{notification && (
<NotificationOverlayContainer
notification={notification}
/>
)}
</div>
</div>
</HotKeys>
);

View file

@ -45,26 +45,19 @@ export default class StatusHeader extends PureComponent {
}
return (
<div className='status__info__account'>
<a
href={account.get('url')}
target='_blank'
className='status__avatar'
onClick={this.handleAccountClick}
rel='noopener noreferrer'
>
<a
href={account.get('url')}
className='status__display-name'
target='_blank'
onClick={this.handleAccountClick}
rel='noopener noreferrer'
>
<div className='status__avatar'>
{statusAvatar}
</a>
<a
href={account.get('url')}
target='_blank'
className='status__display-name'
onClick={this.handleAccountClick}
rel='noopener noreferrer'
>
<DisplayName account={account} />
</a>
</div>
</div>
<DisplayName account={account} />
</a>
);
}

View file

@ -6,7 +6,6 @@ import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
@ -17,8 +16,7 @@ import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { languages } from 'flavours/glitch/initial_state';
import { IconButton } from './icon_button';
import { CollapseButton } from './collapse_button';
import { VisibilityIcon } from './visibility_icon';
const messages = defineMessages({
@ -118,6 +116,7 @@ class StatusIcons extends PureComponent {
mediaIcons,
collapsible,
collapsed,
setCollapsed,
settings,
intl,
} = this.props;
@ -143,21 +142,7 @@ class StatusIcons extends PureComponent {
/>}
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))}
{settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
{collapsible && (
<IconButton
className='status__collapse-button'
animate
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
iconComponent={ExpandLessIcon}
onClick={this.handleCollapsedClick}
/>
)}
{collapsible && <CollapseButton collapsed={collapsed} setCollapsed={setCollapsed} />}
</div>
);
}

View file

@ -5,7 +5,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'flavours/glitch/actions/timelines';
import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'flavours/glitch/features/home_timeline/components/inline_follow_suggestions';
import StatusContainer from '../containers/status_container';
@ -92,24 +94,37 @@ export default class StatusList extends ImmutablePureComponent {
}
let scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId, index) => statusId === null ? (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
) : (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
withCounters={this.props.withCounters}
/>
))
statusIds.map((statusId, index) => {
switch(statusId) {
case TIMELINE_SUGGESTIONS:
return (
<InlineFollowSuggestions
key='inline-follow-suggestions'
/>
);
case TIMELINE_GAP:
return (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
);
default:
return (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
withCounters={this.props.withCounters}
/>
);
}
})
) : null;
if (scrollableContent && featuredStatusIds) {

View file

@ -23,6 +23,7 @@ export default class StatusPrepend extends PureComponent {
account: ImmutablePropTypes.map.isRequired,
parseClick: PropTypes.func.isRequired,
notificationId: PropTypes.number,
children: PropTypes.node,
};
handleClick = (e) => {
@ -38,11 +39,13 @@ export default class StatusPrepend extends PureComponent {
href={account.get('url')}
className='status__display-name'
>
<b
dangerouslySetInnerHTML={{
__html : account.get('display_name_html') || account.get('username'),
}}
/>
<bdi>
<strong
dangerouslySetInnerHTML={{
__html : account.get('display_name_html') || account.get('username'),
}}
/>
</bdi>
</a>
);
switch (type) {
@ -112,7 +115,7 @@ export default class StatusPrepend extends PureComponent {
render () {
const { Message } = this;
const { type } = this.props;
const { type, children } = this.props;
let iconId, iconComponent;
@ -146,14 +149,13 @@ export default class StatusPrepend extends PureComponent {
return !type ? null : (
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
<div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
<Icon
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
id={iconId}
icon={iconComponent}
/>
</div>
<Icon
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
id={iconId}
icon={iconComponent}
/>
<Message />
{children}
</aside>
);
}

View file

@ -1,9 +1,9 @@
import { defineMessages, useIntl } from 'react-intl';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Icon } from './icon';
@ -11,14 +11,17 @@ type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_short: {
id: 'privacy.unlisted.short',
defaultMessage: 'Quiet public',
},
private_short: {
id: 'privacy.private.short',
defaultMessage: 'Followers only',
defaultMessage: 'Followers',
},
direct_short: {
id: 'privacy.direct.short',
defaultMessage: 'Mentioned people only',
defaultMessage: 'Specific people',
},
});
@ -35,7 +38,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
},
unlisted: {
icon: 'unlock',
iconComponent: LockOpenIcon,
iconComponent: QuietTimeIcon,
text: intl.formatMessage(messages.unlisted_short),
},
private: {

View file

@ -1,14 +1,12 @@
import { PureComponent } from 'react';
import { Provider } from 'react-redux';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store';
import Compose from '../features/standalone/compose';
import initialState from '../initial_state';
import { IntlProvider } from '../locales';
import { store } from '../store';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
import { hydrateStore } from 'flavours/glitch/actions/store';
import { Router } from 'flavours/glitch/components/router';
import Compose from 'flavours/glitch/features/standalone/compose';
import initialState from 'flavours/glitch/initial_state';
import { IntlProvider } from 'flavours/glitch/locales';
import { store } from 'flavours/glitch/store';
if (initialState) {
store.dispatch(hydrateStore(initialState));
@ -16,16 +14,14 @@ if (initialState) {
store.dispatch(fetchCustomEmojis());
export default class ComposeContainer extends PureComponent {
const ComposeContainer = () => (
<IntlProvider>
<Provider store={store}>
<Router>
<Compose />
</Router>
</Provider>
</IntlProvider>
);
render () {
return (
<IntlProvider>
<Provider store={store}>
<Compose />
</Provider>
</IntlProvider>
);
}
}
export default ComposeContainer;

View file

@ -7,7 +7,6 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = {

View file

@ -9,15 +9,16 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
@ -45,6 +46,7 @@ const messages = defineMessages({
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
media: { id: 'account.media', defaultMessage: 'Media' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
@ -176,11 +178,10 @@ class Header extends ImmutablePureComponent {
const isRemote = account.get('acct') !== account.get('username');
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
let info = [];
let actionBtn = '';
let bellBtn = '';
let lockedIcon = '';
let menu = [];
let actionBtn, bellBtn, lockedIcon, shareBtn;
let info = [];
let menu = [];
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>);
@ -198,6 +199,12 @@ class Header extends ImmutablePureComponent {
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsActiveIcon : NotificationsIcon} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if ('share' in navigator) {
shareBtn = <IconButton className='optional' iconComponent={ShareIcon} title={intl.formatMessage(messages.share, { name: account.get('username') })} onClick={this.handleShare} />;
} else {
shareBtn = <CopyIconButton className='optional' title={intl.formatMessage(messages.copy)} value={account.get('url')} />;
}
if (me !== account.get('id')) {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = '';
@ -235,11 +242,6 @@ class Header extends ImmutablePureComponent {
menu.push(null);
}
if ('share' in navigator && !account.get('suspended')) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
menu.push(null);
}
if (account.get('id') === me) {
if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
@ -308,7 +310,7 @@ class Header extends ImmutablePureComponent {
}
}
const content = { __html: account.get('note_emojified') };
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields');
const isLocal = account.get('acct').indexOf('@') === -1;
@ -350,6 +352,7 @@ class Header extends ImmutablePureComponent {
<>
{actionBtn}
{bellBtn}
{shareBtn}
</>
)}
@ -372,28 +375,29 @@ class Header extends ImmutablePureComponent {
</div>
)}
{signedIn && <AccountNoteContainer account={account} />}
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{ fields.size > 0 && (
<div className='account__header__fields'>
{fields.map((pair, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
<dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' icon={CheckIcon} className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} className='translate' />
</dd>
</dl>
))}
</div>
)}
{(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
<div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
<div className='account__header__fields'>
<dl>
<dt><FormattedMessage id='account.joined_short' defaultMessage='Joined' /></dt>
<dd>{intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}</dd>
</dl>
{fields.map((pair, i) => (
<dl key={i} className={classNames({ verified: pair.get('verified_at') })}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
<dd className='translate' title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' icon={CheckIcon} className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
))}
</div>
</div>
</div>
)}

View file

@ -1,54 +1,38 @@
import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import TripIcon from '@/material-icons/400-24px/trip.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { AvatarOverlay } from '../../../components/avatar_overlay';
import { DisplayName } from '../../../components/display_name';
import { Permalink } from '../../../components/permalink';
class MovedNote extends ImmutablePureComponent {
export default class MovedNote extends ImmutablePureComponent {
static propTypes = {
from: ImmutablePropTypes.map.isRequired,
to: ImmutablePropTypes.map.isRequired,
...WithRouterPropTypes,
};
handleAccountClick = e => {
if (e.button === 0) {
e.preventDefault();
this.props.history.push(`/@${this.props.to.get('acct')}`);
}
e.stopPropagation();
};
render () {
const { from, to } = this.props;
const displayNameHtml = { __html: from.get('display_name_html') };
return (
<div className='account__moved-note'>
<div className='account__moved-note__message'>
<div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' icon={TripIcon} /></div>
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: from.get('display_name_html') }} /></bdi> }} />
</div>
<a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</a>
<div className='moved-account-banner__action'>
<Permalink to={`/@${to.get('acct')}`} href={to.get('url')} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</Permalink>
<Permalink to={`/@${to.get('acct')}`} href={to.get('url')} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Permalink>
</div>
</div>
);
}
}
export default withRouter(MovedNote);

View file

@ -1,15 +1,13 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useCallback } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch } from 'react-redux';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { logOut } from 'flavours/glitch/utils/log_out';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -25,51 +23,52 @@ const messages = defineMessages({
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
class ActionBar extends PureComponent {
export const ActionBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
const handleLogoutClick = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
}, [dispatch, intl]);
handleLogout = () => {
this.props.onLogout();
};
let menu = [];
render () {
const { intl } = this.props;
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick });
let menu = [];
menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='bars' iconComponent={MenuIcon} size={24} direction='right' />
</div>
</div>
);
}
}
export default injectIntl(ActionBar);
return (
<DropdownMenuContainer
items={menu}
icon='bars'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
);
};

View file

@ -15,8 +15,8 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
return (
<div className='autosuggest-account' title={account.get('acct')}>
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
<DisplayName account={account} inline />
<Avatar account={account} size={24} />
<DisplayName account={account} />
</div>
);
}

View file

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

View file

@ -10,35 +10,39 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { maxChars } from 'flavours/glitch/initial_state';
import { isMobile } from 'flavours/glitch/is_mobile';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
import AutosuggestInput from '../../../components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { Button } from '../../../components/button';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import OptionsContainer from '../containers/options_container';
import PollFormContainer from '../containers/poll_form_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import LanguageDropdown from '../containers/language_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import CharacterCounter from './character_counter';
import Publisher from './publisher';
import TextareaIcons from './textarea_icons';
import { CharacterCounter } from './character_counter';
import { ContentTypeButton } from './content_type_button';
import { EditIndicator } from './edit_indicator';
import { FederationButton } from './federation_button';
import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
import { SecondaryPrivacyButton } from './secondary_privacy_button';
import { ThreadModeButton } from './thread_mode_button';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
missingDescriptionMessage: {
id: 'confirmations.missing_media_description.message',
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
},
missingDescriptionConfirm: {
id: 'confirmations.missing_media_description.confirm',
defaultMessage: 'Send anyway',
},
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
reply: { id: 'compose_form.reply', defaultMessage: 'Reply' },
});
class ComposeForm extends ImmutablePureComponent {
@ -47,11 +51,14 @@ class ComposeForm extends ImmutablePureComponent {
text: PropTypes.string.isRequired,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
spoilerAlwaysOn: PropTypes.bool,
privacy: PropTypes.string,
sideArm: PropTypes.string,
spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
preselectDate: PropTypes.instanceOf(Date),
preselectOnReply: PropTypes.bool,
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isEditing: PropTypes.bool,
@ -64,26 +71,21 @@ class ComposeForm extends ImmutablePureComponent {
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
autoFocus: PropTypes.bool,
withoutNavigation: PropTypes.bool,
anyMedia: PropTypes.bool,
media: ImmutablePropTypes.list,
mediaDescriptionConfirmation: PropTypes.bool,
onMediaDescriptionConfirm: PropTypes.func.isRequired,
isInReply: PropTypes.bool,
singleColumn: PropTypes.bool,
lang: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
media: ImmutablePropTypes.list,
sideArm: PropTypes.string,
sensitive: PropTypes.bool,
spoilersAlwaysOn: PropTypes.bool,
mediaDescriptionConfirmation: PropTypes.bool,
preselectOnReply: PropTypes.bool,
onChangeSpoilerness: PropTypes.func.isRequired,
onChangeVisibility: PropTypes.func.isRequired,
onMediaDescriptionConfirm: PropTypes.func.isRequired,
maxChars: PropTypes.number,
...WithOptionalRouterPropTypes
};
static defaultProps = {
showSearch: false,
autoFocus: false,
};
state = {
@ -101,30 +103,27 @@ class ComposeForm extends ImmutablePureComponent {
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
this.handleSubmit(e);
}
if (e.keyCode === 13 && e.altKey) {
this.handleSecondarySubmit();
this.handleSecondarySubmit(e);
}
};
getFulltextForCharacterCounting = () => {
return [
this.props.spoiler? this.props.spoilerText: '',
countableText(this.props.text),
this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '',
].join('');
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
};
canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = this.props;
const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia));
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
};
handleSubmit = (e, overriddenVisibility = null) => {
handleSubmit = (e, overridePrivacy = null) => {
if (this.props.text !== this.textareaRef.current.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
@ -142,19 +141,15 @@ class ComposeForm extends ImmutablePureComponent {
// Submit unless there are media with missing descriptions
if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) {
const firstWithoutDescription = this.props.media.find(item => !item.get('description'));
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overriddenVisibility);
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overridePrivacy);
} else {
if (overriddenVisibility) {
this.props.onChangeVisibility(overriddenVisibility);
}
this.props.onSubmit(this.props.history || null);
this.props.onSubmit(this.props.history || null, overridePrivacy);
}
};
// Handles the secondary submit button.
handleSecondarySubmit = () => {
handleSecondarySubmit = (e) => {
const { sideArm } = this.props;
this.handleSubmit(null, sideArm === 'none' ? null : sideArm);
this.handleSubmit(e, sideArm === 'none' ? null : sideArm);
};
onSuggestionsClearRequested = () => {
@ -224,7 +219,6 @@ class ComposeForm extends ImmutablePureComponent {
Promise.resolve().then(() => {
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
this.textareaRef.current.focus();
if (!this.props.singleColumn) this.textareaRef.current.scrollIntoView();
this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error);
@ -248,103 +242,109 @@ class ComposeForm extends ImmutablePureComponent {
};
handleEmojiPick = (data) => {
const position = this.textareaRef.current.selectionStart;
const { text } = this.props;
const position = this.textareaRef.current.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data);
this.props.onPickEmoji(position, data, needsSpace);
};
render () {
const {
intl,
advancedOptions,
isSubmitting,
onChangeSpoilerness,
onPaste,
privacy,
sensitive,
showSearch,
sideArm,
spoilersAlwaysOn,
isEditing,
} = this.props;
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
const { highlighted } = this.state;
const disabled = this.props.isSubmitting;
return (
<form className='compose-form' onSubmit={this.handleSubmit}>
<ReplyIndicator />
{!withoutNavigation && <NavigationBar />}
<WarningContainer />
<ReplyIndicatorContainer />
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'>
<EditIndicator />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
autoFocus={false}
spellCheck
/>
</div>
{this.props.spoiler && (
<div className='spoiler-input'>
<div className='spoiler-input__border' />
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth)}
lang={this.props.lang}
>
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
disabled={disabled}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
spellCheck
/>
<div className='compose-form__buttons-wrapper'>
<OptionsContainer
advancedOptions={advancedOptions}
disabled={isSubmitting}
onToggleSpoiler={this.props.spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
isEditing={isEditing}
sensitive={sensitive || (spoilersAlwaysOn && this.props.spoilerText && this.props.spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (this.props.spoilerText && this.props.spoilerText.length > 0) : this.props.spoiler}
<div className='spoiler-input__border' />
</div>
)}
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
/>
<div className='character-counter__wrapper'>
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
</div>
<UploadFormContainer />
<PollForm />
<div className='compose-form__footer'>
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} />
<LanguageDropdown />
</div>
<div className='compose-form__actions'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PollButtonContainer />
{!this.props.spoilerAlwaysOn && <SpoilerButtonContainer />}
<ContentTypeButton />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<FederationButton />
<ThreadModeButton />
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
</div>
<div className='compose-form__submit'>
<SecondaryPrivacyButton
disabled={!this.canSubmit()}
privacy={this.props.sideArm}
isEditing={this.props.isEditing}
onClick={this.handleSecondarySubmit}
/>
<Button
type='submit'
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()}
/>
</div>
</div>
</div>
</div>
<Publisher
disabled={!this.canSubmit()}
isEditing={isEditing}
onSecondarySubmit={this.handleSecondarySubmit}
privacy={privacy}
sideArm={sideArm}
/>
</form>
);
}

View file

@ -0,0 +1,69 @@
import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import SmallCodeIcon from '@/material-icons/400-20px/code.svg?react';
import SmallDescriptionIcon from '@/material-icons/400-20px/description.svg?react';
import SmallMarkdownIcon from '@/material-icons/400-20px/markdown.svg?react';
import CodeIcon from '@/material-icons/400-24px/code.svg?react';
import DescriptionIcon from '@/material-icons/400-24px/description.svg?react';
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
import { changeComposeContentType } from 'flavours/glitch/actions/compose';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { DropdownIconButton } from './dropdown_icon_button';
const messages = defineMessages({
change_content_type: { id: 'compose.content-type.change', defaultMessage: 'Change advanced formatting options' },
plain_text_label: { id: 'compose.content-type.plain', defaultMessage: 'Plain text' },
plain_text_meta: { id: 'compose.content-type.plain_meta', defaultMessage: 'Write with no advanced formatting' },
markdown_label: { id: 'compose.content-type.markdown', defaultMessage: 'Markdown' },
markdown_meta: { id: 'compose.content-type.markdown_meta', defaultMessage: 'Format your posts using Markdown' },
html_label: { id: 'compose.content-type.html', defaultMessage: 'HTML' },
html_meta: { id: 'compose.content-type.html_meta', defaultMessage: 'Format your posts using HTML' },
});
export const ContentTypeButton = () => {
const intl = useIntl();
const showButton = useAppSelector((state) => state.getIn(['local_settings', 'show_content_type_choice']));
const contentType = useAppSelector((state) => state.getIn(['compose', 'content_type']));
const dispatch = useAppDispatch();
const handleChange = useCallback((value) => {
dispatch(changeComposeContentType(value));
}, [dispatch]);
if (!showButton) {
return null;
}
const options = [
{ icon: 'file-text', iconComponent: DescriptionIcon, value: 'text/plain', text: intl.formatMessage(messages.plain_text_label), meta: intl.formatMessage(messages.plain_text_meta) },
{ icon: 'arrow-circle-down', iconComponent: MarkdownIcon, value: 'text/markdown', text: intl.formatMessage(messages.markdown_label), meta: intl.formatMessage(messages.markdown_meta) },
{ icon: 'code', iconComponent: CodeIcon, value: 'text/html', text: intl.formatMessage(messages.html_label), meta: intl.formatMessage(messages.html_meta) },
];
const icon = {
'text/plain': 'file-text',
'text/markdown': 'arrow-circle-down',
'text/html': 'code',
}[contentType];
const iconComponent = {
'text/plain': SmallDescriptionIcon,
'text/markdown': SmallMarkdownIcon,
'text/html': SmallCodeIcon,
}[contentType];
return (
<DropdownIconButton
icon={icon}
iconComponent={iconComponent}
onChange={handleChange}
options={options}
title={intl.formatMessage(messages.change_content_type)}
value={contentType}
/>
);
};

View file

@ -1,243 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
// Components.
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenu from './dropdown_menu';
// The component.
export default class ComposerOptionsDropdown extends PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
disabled: PropTypes.bool,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
iconComponent: PropTypes.func,
meta: PropTypes.string,
name: PropTypes.string.isRequired,
text: PropTypes.string,
})).isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
title: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
container: PropTypes.func,
renderItemContents: PropTypes.func,
closeOnChange: PropTypes.bool,
};
static defaultProps = {
closeOnChange: true,
};
state = {
open: false,
openedViaKeyboard: undefined,
placement: 'bottom',
};
// Toggles opening and closing the dropdown.
handleToggle = ({ type }) => {
const { onModalOpen } = this.props;
const { open } = this.state;
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (open) {
this.props.onModalClose();
} else {
const modal = this.handleMakeModal();
if (modal && onModalOpen) {
onModalOpen(modal);
}
}
} else {
if (open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !open, openedViaKeyboard: type !== 'click' });
}
};
handleKeyDown = (e) => {
switch (e.key) {
case 'Escape':
this.handleClose();
break;
}
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleToggle(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
};
handleItemClick = (e) => {
const {
items,
onChange,
onModalClose,
closeOnChange,
} = this.props;
const i = Number(e.currentTarget.getAttribute('data-index'));
const { name } = items[i];
e.preventDefault(); // Prevents focus from changing
if (closeOnChange) onModalClose();
onChange(name);
};
// Creates an action modal object.
handleMakeModal = () => {
const {
items,
onChange,
onModalOpen,
onModalClose,
value,
} = this.props;
// Required props.
if (!(onChange && onModalOpen && onModalClose && items)) {
return null;
}
// The object.
return {
renderItemContents: this.props.renderItemContents,
onClick: this.handleItemClick,
actions: items.map(
({
name,
...rest
}) => ({
...rest,
active: value && name === value,
name,
}),
),
};
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
// Rendering.
render () {
const {
disabled,
title,
icon,
iconComponent,
items,
onChange,
value,
container,
renderItemContents,
closeOnChange,
} = this.props;
const { open, placement } = this.state;
return (
<div
className={classNames('privacy-dropdown', placement, { active: open })}
onKeyDown={this.handleKeyDown}
ref={this.setTargetRef}
>
<IconButton
active={open}
className='privacy-dropdown__value-icon'
disabled={disabled}
icon={icon}
iconComponent={iconComponent}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
size={18}
style={{
height: null,
lineHeight: '27px',
}}
title={title}
/>
<Overlay
containerPadding={20}
placement={placement}
show={open}
flip
target={this.findTarget}
container={container}
popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<DropdownMenu
items={items}
renderItemContents={renderItemContents}
onChange={onChange}
onClose={this.handleClose}
value={value}
openedViaKeyboard={this.state.openedViaKeyboard}
closeOnChange={closeOnChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}
}

View file

@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import { useCallback, useState, useRef } from 'react';
import Overlay from 'react-overlays/Overlay';
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenu from './dropdown_menu';
export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => {
const containerRef = useRef(null);
const [activeElement, setActiveElement] = useState(null);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
const handleToggle = useCallback(() => {
if (open && activeElement) {
activeElement.focus({ preventScroll: true });
setActiveElement(null);
}
setOpen(!open);
}, [open, setOpen, activeElement, setActiveElement]);
const handleClose = useCallback(() => {
if (open && activeElement) {
activeElement.focus({ preventScroll: true });
setActiveElement(null);
}
setOpen(false);
}, [open, setOpen, activeElement, setActiveElement]);
const handleOverlayEnter = useCallback((state) => {
setPlacement(state.placement);
}, [setPlacement]);
return (
<div ref={containerRef}>
<IconButton
disabled={disabled}
icon={icon}
onClick={handleToggle}
iconComponent={iconComponent}
title={title}
active={open}
size={18}
inverted
/>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={containerRef} popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<DropdownMenu
items={options}
value={value}
onClose={handleClose}
onChange={onChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
};
DropdownIconButton.propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
icon: PropTypes.string,
onChange: PropTypes.func.isRequired,
iconComponent: PropTypes.func.isRequired,
options: PropTypes.array.isRequired,
title: PropTypes.string.isRequired,
};

View file

@ -1,4 +1,3 @@
// Package imports.
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
@ -6,101 +5,34 @@ import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
// Components.
import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
// The component.
export default class ComposerOptionsDropdownContent extends PureComponent {
// copied from PrivacyDropdown; will require refactor with upstream down the line
class DropdownMenu extends PureComponent {
static propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
iconComponent: PropTypes.func,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
text: PropTypes.node,
})),
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
value: PropTypes.string,
renderItemContents: PropTypes.func,
openedViaKeyboard: PropTypes.bool,
closeOnChange: PropTypes.bool,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
closeOnChange: true,
};
state = {
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
};
// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick = (e) => {
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
}
};
// Stores our node in `this.node`.
setRef = (node) => {
this.node = node;
};
// On mounting, we add our listeners.
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) {
this.focusedItem.focus({ preventScroll: true });
} else {
this.node.firstChild.focus({ preventScroll: true });
}
}
// On unmounting, we remove our listeners.
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
handleClick = (e) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const {
onChange,
onClose,
closeOnChange,
items,
} = this.props;
const { name } = items[i];
e.preventDefault(); // Prevents change in focus on click
if (closeOnChange) {
onClose();
}
onChange(name);
};
// Handle changes differently whether the dropdown is a list of options or actions
handleChange = (name) => {
if (this.props.value) {
this.props.onChange(name);
} else {
this.setState({ value: name });
}
};
handleKeyDown = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
handleKeyDown = e => {
const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element = null;
switch(e.key) {
@ -108,7 +40,6 @@ export default class ComposerOptionsDropdownContent extends PureComponent {
this.props.onClose();
break;
case 'Enter':
case ' ':
this.handleClick(e);
break;
case 'ArrowDown':
@ -134,68 +65,61 @@ export default class ComposerOptionsDropdownContent extends PureComponent {
if (element) {
element.focus();
this.handleChange(items[Number(element.getAttribute('data-index'))].name);
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
};
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
renderItem = (item, i) => {
const { name, icon, iconComponent, meta, text } = item;
const active = (name === (this.props.value || this.state.value));
const computedClass = classNames('privacy-dropdown__option', { active });
let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
if (!contents) {
contents = (
<>
{icon && <Icon className='icon' id={icon} icon={iconComponent} />}
<div className='privacy-dropdown__option__content'>
<strong>{text}</strong>
{meta}
</div>
</>
);
}
return (
<div
className={computedClass}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
role='option'
aria-selected={active}
tabIndex={0}
key={name}
data-index={i}
ref={active ? this.setFocusRef : null}
>
{contents}
</div>
);
};
// Rendering.
render () {
const {
items,
style,
} = this.props;
const { style, items, value } = this.props;
// The result.
return (
<div style={{ ...style }} role='listbox' ref={this.setRef}>
{!!items && items.map((item, i) => this.renderItem(item, i))}
{items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
);
}
}
export default DropdownMenu;

View file

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { Permalink } from 'flavours/glitch/components/permalink';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
export const EditIndicator = () => {
const intl = useIntl();
const dispatch = useDispatch();
const id = useSelector(state => state.getIn(['compose', 'id']));
const status = useSelector(state => state.getIn(['statuses', id]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='edit-indicator'>
<div className='edit-indicator__header'>
<div className='edit-indicator__display-name'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`}>@{account.get('acct')}</Permalink>
·
<Permalink href={status.get('url')} to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Permalink>
</div>
<div className='edit-indicator__cancel'>
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted />
</div>
</div>
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
{(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='edit-indicator__attachments'>
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
</div>
)}
</div>
);
};

View file

@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
import { assetHost } from 'flavours/glitch/utils/config';
@ -163,6 +165,7 @@ class EmojiPickerMenuImpl extends PureComponent {
intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
pickerButtonRef: PropTypes.func.isRequired
};
static defaultProps = {
@ -177,7 +180,7 @@ class EmojiPickerMenuImpl extends PureComponent {
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
if (this.node && !this.node.contains(e.target) && !this.props.pickerButtonRef.contains(e.target)) {
this.props.onClose();
}
};
@ -232,6 +235,7 @@ class EmojiPickerMenuImpl extends PureComponent {
emoji.native = emoji.colons;
}
if (!(event.ctrlKey || event.metaKey)) {
this.props.onClose();
}
this.props.onPick(emoji);
@ -323,7 +327,6 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};
state = {
@ -381,23 +384,24 @@ class EmojiPickerDropdown extends PureComponent {
};
render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f642.svg`}
/>}
</div>
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
<IconButton
title={title}
aria-expanded={active}
active={active}
iconComponent={MoodIcon}
onClick={this.onToggle}
inverted
/>
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, placement })=> (
<div {...props} style={{ ...props.style, width: 299 }}>
<div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
@ -407,6 +411,7 @@ class EmojiPickerDropdown extends PureComponent {
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
pickerButtonRef={this.target}
/>
</div>
</div>

View file

@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import SmallShareIcon from '@/material-icons/400-20px/share.svg?react';
import SmallShareOffIcon from '@/material-icons/400-20px/share_off.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import ShareOffIcon from '@/material-icons/400-24px/share_off.svg?react';
import { changeComposeAdvancedOption } from 'flavours/glitch/actions/compose';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { DropdownIconButton } from './dropdown_icon_button';
const messages = defineMessages({
change_federation_settings: { id: 'compose.change_federation', defaultMessage: 'Change federation settings' },
local_only_label: { id: 'federation.local_only.short', defaultMessage: 'Local-only' },
local_only_meta: { id: 'federation.local_only.long', defaultMessage: 'Prevent this post from reaching other servers' },
federated_label: { id: 'federation.federated.short', defaultMessage: 'Federated' },
federated_meta: { id: 'federation.federated.long', defaultMessage: 'Allow this post to reach other servers' },
});
export const FederationButton = () => {
const intl = useIntl();
const isEditing = useAppSelector((state) => state.getIn(['compose', 'id']) !== null);
const do_not_federate = useAppSelector((state) => state.getIn(['compose', 'advanced_options', 'do_not_federate']));
const dispatch = useAppDispatch();
const handleChange = useCallback((value) => {
dispatch(changeComposeAdvancedOption('do_not_federate', value === 'local-only'));
}, [dispatch]);
const options = [
{ icon: 'link', iconComponent: ShareIcon, value: 'federated', text: intl.formatMessage(messages.federated_label), meta: intl.formatMessage(messages.federated_meta) },
{ icon: 'link-slash', iconComponent: ShareOffIcon, value: 'local-only', text: intl.formatMessage(messages.local_only_label), meta: intl.formatMessage(messages.local_only_meta) },
];
return (
<DropdownIconButton
disabled={isEditing}
icon={do_not_federate ? 'link-slash' : 'link'}
iconComponent={do_not_federate ? SmallShareOffIcon : SmallShareIcon}
onChange={handleChange}
options={options}
title={intl.formatMessage(messages.change_federation_settings)}
value={do_not_federate ? 'local-only' : 'federated'}
/>
);
};

View file

@ -1,149 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { signOutLink } from 'flavours/glitch/utils/backend_links';
import { conditionalRender } from 'flavours/glitch/utils/react_helpers';
const messages = defineMessages({
community: {
defaultMessage: 'Local timeline',
id: 'navigation_bar.community_timeline',
},
home_timeline: {
defaultMessage: 'Home',
id: 'tabs_bar.home',
},
logout: {
defaultMessage: 'Logout',
id: 'navigation_bar.logout',
},
notifications: {
defaultMessage: 'Notifications',
id: 'tabs_bar.notifications',
},
public: {
defaultMessage: 'Federated timeline',
id: 'navigation_bar.public_timeline',
},
settings: {
defaultMessage: 'App settings',
id: 'navigation_bar.app_settings',
},
start: {
defaultMessage: 'Getting started',
id: 'getting_started.heading',
},
});
class Header extends ImmutablePureComponent {
static propTypes = {
columns: ImmutablePropTypes.list,
unreadNotifications: PropTypes.number,
showNotificationsBadge: PropTypes.bool,
intl: PropTypes.object,
onSettingsClick: PropTypes.func,
onLogout: PropTypes.func.isRequired,
};
handleLogoutClick = e => {
e.preventDefault();
e.stopPropagation();
this.props.onLogout();
return false;
};
render () {
const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(null,
columnId => !columns || !columns.some(
column => column.get('id') === columnId,
),
);
// The result.
return (
<nav className='drawer__header'>
<Link
aria-label={intl.formatMessage(messages.start)}
title={intl.formatMessage(messages.start)}
to='/getting-started'
className='drawer__tab'
><Icon id='bars' icon={MenuIcon} /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
title={intl.formatMessage(messages.home_timeline)}
to='/home'
className='drawer__tab'
><Icon id='home' icon={HomeIcon} /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
title={intl.formatMessage(messages.notifications)}
to='/notifications'
className='drawer__tab'
>
<span className='icon-badge-wrapper'>
<Icon id='bell' icon={NotificationsIcon} />
{ showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
</span>
</Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
title={intl.formatMessage(messages.community)}
to='/public/local'
className='drawer__tab'
><Icon id='users' icon={PeopleIcon} /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
title={intl.formatMessage(messages.public)}
to='/public'
className='drawer__tab'
><Icon id='globe' icon={PublicIcon} /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
onClick={onSettingsClick}
href='/settings/preferences'
title={intl.formatMessage(messages.settings)}
className='drawer__tab'
><Icon id='cogs' icon={ManufacturingIcon} /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
onClick={this.handleLogoutClick}
href={signOutLink}
title={intl.formatMessage(messages.logout)}
className='drawer__tab'
><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
);
}
}
export default injectIntl(Header);

View file

@ -9,10 +9,11 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import fuzzysort from 'fuzzysort';
import Overlay from 'react-overlays/Overlay';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TranslateIcon from '@/material-icons/400-24px/translate.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
import TextIconButton from './text_icon_button';
const messages = defineMessages({
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
@ -231,7 +232,7 @@ class LanguageDropdownMenu extends PureComponent {
<div ref={this.setRef}>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
@ -297,20 +298,24 @@ class LanguageDropdown extends PureComponent {
render () {
const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state;
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
return (
<div className={classNames('privacy-dropdown', placement, { active: open })}>
<div className='privacy-dropdown__value' ref={this.setTargetRef} >
<TextIconButton
className='privacy-dropdown__value-icon'
label={value && value.toUpperCase()}
title={intl.formatMessage(messages.changeLanguage)}
active={open}
onClick={this.handleToggle}
/>
</div>
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.changeLanguage)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
className={classNames('dropdown-button', { active: open })}
>
<Icon icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span>
</button>
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >

View file

@ -1,54 +1,36 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useIntl, defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { useSelector, useDispatch } from 'react-redux';
import { Permalink } from 'flavours/glitch/components/permalink';
import { profileLink } from 'flavours/glitch/utils/backend_links';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
import Account from 'flavours/glitch/components/account';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { me } from 'flavours/glitch/initial_state';
import { Avatar } from '../../../components/avatar';
import { ActionBar } from './action_bar';
import ActionBar from './action_bar';
export default class NavigationBar extends ImmutablePureComponent {
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func,
};
export const NavigationBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', me]));
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
render () {
const username = this.props.account.get('acct');
return (
<div className='navigation-bar'>
<Permalink className='avatar' href={this.props.account.get('url')} to={`/@${username}`}>
<span style={{ display: 'none' }}>{username}</span>
<Avatar account={this.props.account} size={46} />
</Permalink>
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
<div className='navigation-bar__profile'>
<span>
<Permalink className='acct' href={this.props.account.get('url')} to={`/@${username}`}>
<strong className='navigation-bar__profile-account'>@{username}</strong>
</Permalink>
</span>
{ profileLink !== undefined && (
<a
href={profileLink}
className='navigation-bar__profile-edit'
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
)}
</div>
<div className='navigation-bar__actions'>
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
</div>
</div>
);
}
}
return (
<div className='navigation-bar'>
<Account account={account} minimal />
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
</div>
);
};

View file

@ -1,328 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import AttachFileIcon from '@/material-icons/400-24px/attach_file.svg?react';
import BrushIcon from '@/material-icons/400-24px/brush.svg?react';
import CodeIcon from '@/material-icons/400-24px/code.svg?react';
import DescriptionIcon from '@/material-icons/400-24px/description.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { pollLimits } from 'flavours/glitch/initial_state';
import DropdownContainer from '../containers/dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import TextIconButton from './text_icon_button';
const messages = defineMessages({
advanced_options_icon_title: {
defaultMessage: 'Advanced options',
id: 'advanced_options.icon_title',
},
attach: {
defaultMessage: 'Attach...',
id: 'compose.attach',
},
content_type: {
defaultMessage: 'Content type',
id: 'content-type.change',
},
doodle: {
defaultMessage: 'Draw something',
id: 'compose.attach.doodle',
},
html: {
defaultMessage: 'HTML',
id: 'compose.content-type.html',
},
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',
},
markdown: {
defaultMessage: 'Markdown',
id: 'compose.content-type.markdown',
},
plain: {
defaultMessage: 'Plain text',
id: 'compose.content-type.plain',
},
spoiler: {
defaultMessage: 'Hide text behind warning',
id: 'compose_form.spoiler',
},
threaded_mode_long: {
defaultMessage: 'Automatically opens a reply on posting',
id: 'advanced_options.threaded_mode.long',
},
threaded_mode_short: {
defaultMessage: 'Threaded mode',
id: 'advanced_options.threaded_mode.short',
},
upload: {
defaultMessage: 'Upload a file',
id: 'compose.attach.upload',
},
add_poll: {
defaultMessage: 'Add a poll',
id: 'poll_button.add_poll',
},
remove_poll: {
defaultMessage: 'Remove poll',
id: 'poll_button.remove_poll',
},
});
const mapStateToProps = (state, { name }) => ({
checked: state.getIn(['compose', 'advanced_options', name]),
});
class ToggleOptionImpl extends ImmutablePureComponent {
static propTypes = {
name: PropTypes.string.isRequired,
checked: PropTypes.bool,
onChangeAdvancedOption: PropTypes.func.isRequired,
};
handleChange = () => {
this.props.onChangeAdvancedOption(this.props.name);
};
render() {
const { meta, text, checked } = this.props;
return (
<>
<Toggle checked={checked} onChange={this.handleChange} />
<div className='privacy-dropdown__option__content'>
<strong>{text}</strong>
{meta}
</div>
</>
);
}
}
const ToggleOption = connect(mapStateToProps)(ToggleOptionImpl);
class ComposerOptions extends ImmutablePureComponent {
static propTypes = {
acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
disabled: PropTypes.bool,
allowMedia: PropTypes.bool,
allowPoll: PropTypes.bool,
hasPoll: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChangeAdvancedOption: PropTypes.func.isRequired,
onChangeContentType: PropTypes.func.isRequired,
onTogglePoll: PropTypes.func.isRequired,
onDoodleOpen: PropTypes.func.isRequired,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func.isRequired,
contentType: PropTypes.string,
resetFileKey: PropTypes.number,
spoiler: PropTypes.bool,
showContentTypeChoice: PropTypes.bool,
isEditing: PropTypes.bool,
};
handleChangeFiles = ({ target: { files } }) => {
const { onUpload } = this.props;
if (files.length) {
onUpload(files);
}
};
handleClickAttach = (name) => {
const { fileElement } = this;
const { onDoodleOpen } = this.props;
switch (name) {
case 'upload':
if (fileElement) {
fileElement.click();
}
return;
case 'doodle':
onDoodleOpen();
return;
}
};
handleRefFileElement = (fileElement) => {
this.fileElement = fileElement;
};
renderToggleItemContents = (item) => {
const { onChangeAdvancedOption } = this.props;
const { name, meta, text } = item;
return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />;
};
render () {
const {
acceptContentTypes,
advancedOptions,
contentType,
disabled,
allowMedia,
allowPoll,
hasPoll,
onChangeAdvancedOption,
onChangeContentType,
onTogglePoll,
onToggleSpoiler,
resetFileKey,
spoiler,
showContentTypeChoice,
isEditing,
intl: { formatMessage },
} = this.props;
const contentTypeItems = {
plain: {
icon: 'file-text',
iconComponent: DescriptionIcon,
name: 'text/plain',
text: formatMessage(messages.plain),
},
html: {
icon: 'code',
iconComponent: CodeIcon,
name: 'text/html',
text: formatMessage(messages.html),
},
markdown: {
icon: 'arrow-circle-down',
iconComponent: MarkdownIcon,
name: 'text/markdown',
text: formatMessage(messages.markdown),
},
};
// The result.
return (
<div className='compose-form__buttons'>
<input
accept={acceptContentTypes}
disabled={disabled || !allowMedia}
key={resetFileKey}
onChange={this.handleChangeFiles}
ref={this.handleRefFileElement}
type='file'
multiple
style={{ display: 'none' }}
/>
<DropdownContainer
disabled={disabled || !allowMedia}
icon='paperclip'
iconComponent={AttachFileIcon}
items={[
{
icon: 'cloud-upload',
iconComponent: UploadFileIcon,
name: 'upload',
text: formatMessage(messages.upload),
},
{
icon: 'paint-brush',
iconComponent: BrushIcon,
name: 'doodle',
text: formatMessage(messages.doodle),
},
]}
onChange={this.handleClickAttach}
title={formatMessage(messages.attach)}
/>
{!!pollLimits && (
<IconButton
active={hasPoll}
disabled={disabled || !allowPoll}
icon='tasks'
iconComponent={InsertChartIcon}
inverted
onClick={onTogglePoll}
size={18}
style={{
height: null,
lineHeight: null,
}}
title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
/>
)}
<PrivacyDropdownContainer disabled={disabled || isEditing} />
{showContentTypeChoice && (
<DropdownContainer
disabled={disabled}
icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
iconComponent={(contentTypeItems[contentType.split('/')[1]] || {}).iconComponent}
items={[
contentTypeItems.plain,
contentTypeItems.html,
contentTypeItems.markdown,
]}
onChange={onChangeContentType}
title={formatMessage(messages.content_type)}
value={contentType}
/>
)}
{onToggleSpoiler && (
<TextIconButton
active={spoiler}
ariaControls='cw-spoiler-input'
label='CW'
onClick={onToggleSpoiler}
title={formatMessage(messages.spoiler)}
/>
)}
<LanguageDropdown />
<DropdownContainer
disabled={disabled || isEditing}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
items={advancedOptions ? [
{
meta: formatMessage(messages.local_only_long),
name: 'do_not_federate',
text: formatMessage(messages.local_only_short),
},
{
meta: formatMessage(messages.threaded_mode_long),
name: 'threaded_mode',
text: formatMessage(messages.threaded_mode_short),
},
] : null}
onChange={onChangeAdvancedOption}
renderItemContents={this.renderToggleItemContents}
title={formatMessage(messages.advanced_options_icon_title)}
closeOnChange={false}
/>
</div>
);
}
}
export default injectIntl(ComposerOptions);

View file

@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
});
const iconStyle = {
height: null,
lineHeight: '27px',
};
class PollButton extends PureComponent {
static propTypes = {
disabled: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onClick();
};
render () {
const { intl, active, disabled } = this.props;
return (
<div className='compose-form__poll-button'>
<IconButton
icon='tasks'
iconComponent={BarChart4BarsIcon}
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
disabled={disabled}
onClick={this.handleClick}
className={`compose-form__poll-button-icon ${active ? 'active' : ''}`}
size={18}
inverted
style={iconStyle}
/>
</div>
);
}
}
export default injectIntl(PollButton);

View file

@ -1,179 +1,161 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useCallback } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { useDispatch, useSelector } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import {
changePollSettings,
changePollOption,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from 'flavours/glitch/actions/compose';
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { pollLimits } from 'flavours/glitch/initial_state';
const messages = defineMessages({
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' },
multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' },
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' },
multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' },
});
class OptionIntl extends PureComponent {
const Select = ({ label, options, value, onChange }) => {
return (
<label className='compose-form__poll__select'>
<span className='compose-form__poll__select__label'>{label}</span>
static propTypes = {
title: PropTypes.string.isRequired,
lang: PropTypes.string,
index: PropTypes.number.isRequired,
isPollMultiple: PropTypes.bool,
autoFocus: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
<select className='compose-form__poll__select__value' value={value} onChange={onChange}>
{options.map((option, i) => (
<option key={i} value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};
handleOptionTitleChange = e => {
this.props.onChange(this.props.index, e.target.value);
};
Select.propTypes = {
label: PropTypes.node,
value: PropTypes.any,
onChange: PropTypes.func,
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
})),
};
handleOptionRemove = () => {
this.props.onRemove(this.props.index);
};
const Option = ({ multipleChoice, index, title, autoFocus }) => {
const intl = useIntl();
const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language']));
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
};
const handleChange = useCallback(({ target: { value } }) => {
dispatch(changePollOption(index, value));
}, [dispatch, index]);
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
};
const handleSuggestionsFetchRequested = useCallback(token => {
dispatch(fetchComposeSuggestions(token));
}, [dispatch]);
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
};
const handleSuggestionsClearRequested = useCallback(() => {
dispatch(clearComposeSuggestions());
}, [dispatch]);
render () {
const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
}, [dispatch, index]);
return (
<li>
<label className='poll__option editable'>
<span className={classNames('poll__input', { checkbox: isPollMultiple })} />
return (
<label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
<span className={classNames('poll__input', { checkbox: multipleChoice })} />
<AutosuggestInput
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={pollLimits.max_option_chars}
value={title}
lang={lang}
spellCheck
onChange={this.handleOptionTitleChange}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
<AutosuggestInput
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={pollLimits.max_option_chars}
value={title}
lang={lang}
spellCheck
onChange={handleChange}
suggestions={suggestions}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
onSuggestionSelected={handleSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
);
};
<div className='poll__cancel'>
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} />
</div>
</li>
);
Option.propTypes = {
title: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
multipleChoice: PropTypes.bool,
autoFocus: PropTypes.bool,
};
export const PollForm = () => {
const intl = useIntl();
const dispatch = useDispatch();
const poll = useSelector(state => state.getIn(['compose', 'poll']));
const options = poll?.get('options');
const expiresIn = poll?.get('expires_in');
const isMultiple = poll?.get('multiple');
const handleDurationChange = useCallback(({ target: { value } }) => {
dispatch(changePollSettings(value, isMultiple));
}, [dispatch, isMultiple]);
const handleTypeChange = useCallback(({ target: { value } }) => {
dispatch(changePollSettings(expiresIn, value === 'true'));
}, [dispatch, expiresIn]);
if (poll === null) {
return null;
}
}
return (
<div className='compose-form__poll'>
{options.map((title, i) => (
<Option
title={title}
key={i}
index={i}
multipleChoice={isMultiple}
autoFocus={i === 0}
/>
))}
const Option = injectIntl(OptionIntl);
<div className='compose-form__poll__footer'>
<Select label={intl.formatMessage(messages.duration)} options={[
{ value: 300, label: intl.formatMessage(messages.minutes, { number: 5 })},
{ value: 1800, label: intl.formatMessage(messages.minutes, { number: 30 })},
{ value: 3600, label: intl.formatMessage(messages.hours, { number: 1 })},
{ value: 21600, label: intl.formatMessage(messages.hours, { number: 6 })},
{ value: 43200, label: intl.formatMessage(messages.hours, { number: 12 })},
{ value: 86400, label: intl.formatMessage(messages.days, { number: 1 })},
{ value: 259200, label: intl.formatMessage(messages.days, { number: 3 })},
{ value: 604800, label: intl.formatMessage(messages.days, { number: 7 })},
]} value={expiresIn} onChange={handleDurationChange} />
class PollForm extends ImmutablePureComponent {
<div className='compose-form__poll__footer__sep' />
static propTypes = {
options: ImmutablePropTypes.list,
lang: PropTypes.string,
expiresIn: PropTypes.number,
isMultiple: PropTypes.bool,
onChangeOption: PropTypes.func.isRequired,
onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleAddOption = () => {
this.props.onAddOption('');
};
handleSelectDuration = e => {
this.props.onChangeSettings(e.target.value, this.props.isMultiple);
};
handleSelectMultiple = e => {
this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true');
};
render () {
const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
if (!options) {
return null;
}
const autoFocusIndex = options.indexOf('');
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
{options.size < pollLimits.max_options && (
<label className='poll__text editable'>
<span className={classNames('poll__input')} style={{ opacity: 0 }} />
<button className='button button-secondary' onClick={this.handleAddOption} type='button'><Icon id='plus' icon={AddIcon} /> <FormattedMessage {...messages.add_option} /></button>
</label>
)}
</ul>
<div className='poll__footer'>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}>
<option value='false'>{intl.formatMessage(messages.single_choice)}</option>
<option value='true'>{intl.formatMessage(messages.multiple_choices)}</option>
</select>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={expiresIn} onChange={this.handleSelectDuration}>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
</select>
</div>
<Select label={intl.formatMessage(messages.type)} options={[
{ value: false, label: intl.formatMessage(messages.singleChoice) },
{ value: true, label: intl.formatMessage(messages.multipleChoice) },
]} value={isMultiple} onChange={handleTypeChange} />
</div>
);
}
}
export default injectIntl(PollForm);
</div>
);
};

View file

@ -3,25 +3,151 @@ import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import classNames from 'classnames';
import Dropdown from './dropdown';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
class PrivacyDropdownMenu extends 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();
e.stopPropagation();
}
};
handleKeyDown = e => {
const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element = null;
switch(e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.node.childNodes[index - 1] || this.node.lastChild;
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
break;
case 'Home':
element = this.node.firstChild;
break;
case 'End':
element = this.node.lastChild;
break;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
};
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
render () {
const { style, items, value } = this.props;
return (
<div style={{ ...style }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
{item.extra && (
<div className='privacy-dropdown__option__additional' title={item.extra}>
<Icon id='info-circle' icon={InfoIcon} />
</div>
)}
</div>
))}
</div>
);
}
}
class PrivacyDropdown extends PureComponent {
static propTypes = {
@ -36,62 +162,118 @@ class PrivacyDropdown extends PureComponent {
intl: PropTypes.object.isRequired,
};
render () {
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props;
state = {
open: false,
placement: 'bottom',
};
// We predefine our privacy items so that we can easily pick the
// dropdown icon later.
const privacyItems = {
direct: {
icon: 'envelope',
iconComponent: MailIcon,
meta: formatMessage(messages.direct_long),
name: 'direct',
text: formatMessage(messages.direct_short),
},
private: {
icon: 'lock',
iconComponent: LockIcon,
meta: formatMessage(messages.private_long),
name: 'private',
text: formatMessage(messages.private_short),
},
public: {
icon: 'globe',
iconComponent: PublicIcon,
meta: formatMessage(messages.public_long),
name: 'public',
text: formatMessage(messages.public_short),
},
unlisted: {
icon: 'unlock',
iconComponent: LockOpenIcon,
meta: formatMessage(messages.unlisted_long),
name: 'unlisted',
text: formatMessage(messages.unlisted_short),
},
};
const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private];
if (!noDirect) {
items.push(privacyItems.direct);
handleToggle = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
};
handleKeyDown = e => {
switch(e.key) {
case 'Escape':
this.handleClose();
break;
}
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
};
handleChange = value => {
this.props.onChange(value);
};
UNSAFE_componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock', iconComponent: QuietTimeIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
{ icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
];
if (!this.props.noDirect) {
this.options.push(
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
);
}
}
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
render () {
const { value, container, disabled, intl } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<Dropdown
disabled={disabled}
icon={(privacyItems[value] || {}).icon}
iconComponent={(privacyItems[value] || {}).iconComponent}
items={items}
onChange={onChange}
isUserTouching={isUserTouching}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={formatMessage(messages.change_privacy)}
container={container}
value={value}
/>
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.change_privacy)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: open })}
>
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
<span className='dropdown-button__label'>{valueOption.text}</span>
</button>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<PrivacyDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}

View file

@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { Button } from 'flavours/glitch/components/button';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
publish: {
defaultMessage: 'Publish',
id: 'compose_form.publish',
},
publishLoud: {
defaultMessage: '{publish}!',
id: 'compose_form.publish_loud',
},
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});
class Publisher extends ImmutablePureComponent {
static propTypes = {
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onSecondarySubmit: PropTypes.func,
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
isEditing: PropTypes.bool,
};
render () {
const { disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
const privacyIcons = {
direct: {
id: 'envelope',
icon: MailIcon,
},
private: {
id: 'lock',
icon: LockIcon,
},
public: {
id: 'globe',
icon: PublicIcon,
},
unlisted: {
id: 'unlock',
icon: LockOpenIcon,
},
};
let publishText;
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'private' || privacy === 'direct') {
const icon = privacyIcons[privacy];
publishText = (
<span>
<Icon {...icon} /> {intl.formatMessage(messages.publish)}
</span>
);
} else {
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
const privacyNames = {
public: messages.public,
unlisted: messages.unlisted,
private: messages.private,
direct: messages.direct,
};
return (
<div className='compose-form__publish'>
{sideArm && !isEditing && sideArm !== 'none' && (
<div className='compose-form__publish-button-wrapper'>
<Button
className='side_arm'
disabled={disabled}
onClick={onSecondarySubmit}
style={{ padding: null }}
text={<Icon {...privacyIcons[sideArm]} />}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[sideArm])}`}
/>
</div>
)}
<div className='compose-form__publish-button-wrapper'>
<Button
className='primary'
type='submit'
text={publishText}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[privacy])}`}
disabled={disabled}
/>
</div>
</div>
);
}
}
export default injectIntl(Publisher);

View file

@ -1,75 +1,47 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { useSelector } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { Avatar } from 'flavours/glitch/components/avatar';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon';
import { Permalink } from 'flavours/glitch/components/permalink';
export const ReplyIndicator = () => {
const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
const status = useSelector(state => state.getIn(['statuses', inReplyToId]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import AttachmentList from 'flavours/glitch/components/attachment_list';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
if (!status) {
return null;
}
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
const content = { __html: status.get('contentHtml') };
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
return (
<div className='reply-indicator'>
<div className='reply-indicator__line' />
class ReplyIndicator extends ImmutablePureComponent {
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
<Avatar account={account} size={46} />
</Permalink>
static propTypes = {
status: ImmutablePropTypes.map,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
...WithOptionalRouterPropTypes,
};
handleClick = () => {
this.props.onCancel();
};
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history?.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
};
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' iconComponent={CloseIcon} onClick={this.handleClick} inverted /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' target='_blank' rel='noopener noreferrer'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} inline />
</a>
</div>
<div className='reply-indicator__main'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
<DisplayName account={account} />
</Permalink>
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='reply-indicator__attachments'>
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
</div>
)}
</div>
);
}
}
export default withOptionalRouter(injectIntl(ReplyIndicator));
</div>
);
};

View file

@ -8,7 +8,6 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@ -186,9 +185,9 @@ class Search extends PureComponent {
};
handleURLClick = () => {
const { onOpenURL, history } = this.props;
const { value, onOpenURL, history } = this.props;
onOpenURL(history);
onOpenURL(value, history);
this._unfocus();
};
@ -331,7 +330,7 @@ class Search extends PureComponent {
type='text'
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value || ''}
value={value}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
@ -339,8 +338,8 @@ class Search extends PureComponent {
/>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} />
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
</div>
<div className='search__popout'>

View file

@ -13,7 +13,6 @@ import { Icon } from 'flavours/glitch/components/icon';
import { LoadMore } from 'flavours/glitch/components/load_more';
import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
@ -77,10 +76,10 @@ class SearchResults extends ImmutablePureComponent {
return (
<div className='search-results'>
<header className='search-results__header'>
<div className='search-results__header'>
<Icon id='search' icon={SearchIcon} />
<FormattedMessage id='explore.search_results' defaultMessage='Search results' />
</header>
</div>
{accounts}
{hashtags}

View file

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import { useIntl, defineMessages } from 'react-intl';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Button } from 'flavours/glitch/components/button';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
});
export const SecondaryPrivacyButton = ({ disabled, privacy, isEditing, onClick }) => {
const intl = useIntl();
if (isEditing || !privacy || privacy === 'none') {
return null;
}
const privacyProps = {
direct: { icon: 'envelope', iconComponent: MailIcon, title: messages.direct },
private: { icon: 'lock', iconComponent: LockIcon, title: messages.private },
public: { icon: 'globe', iconComponent: PublicIcon, title: messages.public },
unlisted: { icon: 'unlock', iconComponent: QuietTimeIcon, title: messages.unlisted },
};
return (
<Button className='secondary-post-button' disabled={disabled} onClick={onClick} title={intl.formatMessage(privacyProps[privacy].title)}>
<Icon id={privacyProps[privacy].id} icon={privacyProps[privacy].iconComponent} />
</Button>
);
};
SecondaryPrivacyButton.propTypes = {
disabled: PropTypes.bool,
privacy: PropTypes.string,
isEditing: PropTypes.bool,
onClick: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const messages = defineMessages({
marked: {
id: 'compose_form.sensitive.marked',
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
},
unmarked: {
id: 'compose_form.sensitive.unmarked',
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
},
});
export const SensitiveButton = () => {
const intl = useIntl();
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field']));
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text']));
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive']));
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler']));
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size);
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(changeComposeSensitivity());
}, [dispatch]);
return (
<div className='compose-form__sensitive-button'>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
<input
name='mark-sensitive'
type='checkbox'
checked={active}
onChange={handleClick}
disabled={disabled}
/>
<FormattedMessage
id='compose_form.sensitive.hide'
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
values={{ count: mediaCount }}
/>
</label>
</div>
);
};

View file

@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
localOnly: {
defaultMessage: 'This post is local-only',
id: 'advanced_options.local-only.tooltip',
},
threadedMode: {
defaultMessage: 'Threaded mode enabled',
id: 'advanced_options.threaded_mode.tooltip',
},
});
// We use an array of tuples here instead of an object because it
// preserves order.
const iconMap = [
['do_not_federate', 'home', HomeIcon, messages.localOnly],
['threaded_mode', 'comments', ForumIcon, messages.threadedMode],
];
class TextareaIcons extends ImmutablePureComponent {
static propTypes = {
advancedOptions: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};
render () {
const { advancedOptions, intl } = this.props;
return (
<div className='compose-form__textarea-icons'>
{advancedOptions && iconMap.map(
([key, icon, iconComponent, message]) => advancedOptions.get(key) && (
<span
className='textarea_icon'
key={key}
title={intl.formatMessage(message)}
>
<Icon id={icon} icon={iconComponent} />
</span>
),
)}
</div>
);
}
}
export default injectIntl(TextareaIcons);

View file

@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import QuickreplyIcon from '@/material-icons/400-20px/quickreply.svg?react';
import { changeComposeAdvancedOption } from 'flavours/glitch/actions/compose';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const messages = defineMessages({
enable_threaded_mode: { id: 'compose.enable_threaded_mode', defaultMessage: 'Enable threaded mode' },
disable_threaded_mode: { id: 'compose.disable_threaded_mode', defaultMessage: 'Disable threaded mode' },
});
export const ThreadModeButton = () => {
const intl = useIntl();
const isEditing = useAppSelector((state) => state.getIn(['compose', 'id']) !== null);
const active = useAppSelector((state) => state.getIn(['compose', 'advanced_options', 'threaded_mode']));
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(changeComposeAdvancedOption('threaded_mode', !active));
}, [active, dispatch]);
const title = intl.formatMessage(active ? messages.disable_threaded_mode : messages.enable_threaded_mode);
return (
<IconButton
disabled={isEditing}
icon=''
onClick={handleClick}
iconComponent={QuickreplyIcon}
title={title}
active={active}
size={18}
inverted
/>
);
};

View file

@ -2,23 +2,26 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import spring from 'react-motion/lib/spring';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { Blurhash } from 'flavours/glitch/components/blurhash';
import { Icon } from 'flavours/glitch/components/icon';
import Motion from '../../ui/util/optional_motion';
export default class Upload extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
sensitive: PropTypes.bool,
onUndo: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
};
@ -34,7 +37,7 @@ export default class Upload extends ImmutablePureComponent {
};
render () {
const { media } = this.props;
const { media, sensitive } = this.props;
if (!media) {
return null;
@ -44,22 +47,26 @@ export default class Upload extends ImmutablePureComponent {
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const missingDescription = (media.get('description') || '').length === 0;
return (
<div className='compose-form__upload'>
<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')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
{sensitive && <Blurhash
hash={media.get('blurhash')}
className='compose-form__upload__preview'
/>}
<div className='compose-form__upload__actions'>
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
<button type='button' className='icon-button compose-form__upload__delete' onClick={this.handleUndoClick}><Icon icon={CloseIcon} /></button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div>
{(media.get('description') || '').length === 0 && (
<div className='compose-form__upload__warning'>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' icon={InfoIcon} /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
</div>
)}
<div className='compose-form__upload__warning'>
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
</div>
</div>
)}
</Motion>

View file

@ -0,0 +1,82 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import PhotoLibraryIcon from '@/material-icons/400-20px/photo_library.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
});
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
});
return mapStateToProps;
};
const iconStyle = {
height: null,
lineHeight: '27px',
};
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;
const message = intl.formatMessage(messages.upload);
return (
<div className='compose-form__upload-button'>
<IconButton icon='paperclip' iconComponent={PhotoLibraryIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<label>
<span style={{ display: 'none' }}>{message}</span>
<input
key={resetFileKey}
ref={this.setRef}
type='file'
name='file-upload-input'
multiple
accept={acceptContentTypes.toArray().join(',')}
onChange={this.handleChange}
disabled={disabled}
style={{ display: 'none' }}
/>
</label>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(UploadButton));

View file

@ -1,10 +1,11 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import UploadContainer from '../containers/upload_container';
import UploadProgressContainer from '../containers/upload_progress_container';
import { SensitiveButton } from './sensitive_button';
export default class UploadForm extends ImmutablePureComponent {
static propTypes = {
@ -15,17 +16,19 @@ export default class UploadForm extends ImmutablePureComponent {
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>
{mediaIds.size > 0 && (
<div className='compose-form__uploads'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
)}
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
</div>
{!mediaIds.isEmpty() && <SensitiveButton />}
</>
);
}

View file

@ -35,9 +35,7 @@ export default class UploadProgress extends PureComponent {
return (
<div className='upload-progress'>
<div className='upload-progress__icon'>
<Icon id='upload' icon={UploadFileIcon} />
</div>
<Icon id='upload' icon={UploadFileIcon} />
<div className='upload-progress__message'>
{message}

View file

@ -11,8 +11,6 @@ import {
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
changeComposeSpoilerness,
changeComposeVisibility,
insertEmojiCompose,
uploadCompose,
} from '../../../actions/compose';
@ -58,11 +56,13 @@ const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['local_settings', 'always_show_spoilers_field']) || state.getIn(['compose', 'spoiler']),
spoilerAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isEditing: state.getIn(['compose', 'id']) !== null,
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
@ -70,14 +70,10 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
advancedOptions: state.getIn(['compose', 'advanced_options']),
media: state.getIn(['compose', 'media_attachments']),
sideArm: sideArmPrivacy(state),
sensitive: state.getIn(['compose', 'sensitive']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
spoilersAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']),
media: state.getIn(['compose', 'media_attachments']),
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
});
const mapDispatchToProps = (dispatch, { intl }) => ({
@ -86,8 +82,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(changeCompose(text));
},
onSubmit (router) {
dispatch(submitCompose(router));
onSubmit (router, overridePrivacy = null) {
dispatch(submitCompose(router, overridePrivacy));
},
onClearSuggestions () {
@ -102,37 +98,26 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText (text) {
dispatch(changeComposeSpoilerText(text));
onChangeSpoilerText (checked) {
dispatch(changeComposeSpoilerText(checked));
},
onPaste (files) {
dispatch(uploadCompose(files));
},
onPickEmoji (position, emoji) {
dispatch(insertEmojiCompose(position, emoji));
onPickEmoji (position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
onChangeSpoilerness() {
dispatch(changeComposeSpoilerness());
},
onChangeVisibility(value) {
dispatch(changeComposeVisibility(value));
},
onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) {
onMediaDescriptionConfirm (routerHistory, mediaId, overridePrivacy = null) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.missingDescriptionMessage),
confirm: intl.formatMessage(messages.missingDescriptionConfirm),
onConfirm: () => {
if (overriddenVisibility) {
dispatch(changeComposeVisibility(overriddenVisibility));
}
dispatch(submitCompose(routerHistory));
dispatch(submitCompose(routerHistory, overridePrivacy));
},
secondary: intl.formatMessage(messages.missingDescriptionEdit),
onSecondary: () => dispatch(openModal({

View file

@ -1,14 +0,0 @@
import { connect } from 'react-redux';
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import { isUserTouching } from 'flavours/glitch/is_mobile';
import Dropdown from '../components/dropdown';
const mapDispatchToProps = dispatch => ({
isUserTouching,
onModalOpen: props => dispatch(openModal({ modalType: 'ACTIONS', modalProps: props })),
onModalClose: () => dispatch(closeModal({ modalType: undefined, ignoreFocus: false })),
});
export default connect(null, mapDispatchToProps)(Dropdown);

View file

@ -1,42 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { logOut } from 'flavours/glitch/utils/log_out';
import Header from '../components/header';
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapStateToProps = state => {
return {
columns: state.getIn(['settings', 'columns']),
unreadNotifications: state.getIn(['notifications', 'unread']),
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onSettingsClick (e) {
e.preventDefault();
e.stopPropagation();
dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} }));
},
onLogout () {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));

View file

@ -1,36 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { logOut } from 'flavours/glitch/utils/log_out';
import { me } from '../../../initial_state';
import NavigationBar from '../components/navigation_bar';
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));

View file

@ -1,56 +0,0 @@
import { connect } from 'react-redux';
import {
changeComposeAdvancedOption,
changeComposeContentType,
addPoll,
removePoll,
} from 'flavours/glitch/actions/compose';
import { openModal } from 'flavours/glitch/actions/modal';
import Options from '../components/options';
function mapStateToProps (state) {
const poll = state.getIn(['compose', 'poll']);
const media = state.getIn(['compose', 'media_attachments']);
const pending_media = state.getIn(['compose', 'pending_media_attachments']);
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
hasPoll: !!poll,
allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
allowPoll: !(media && !!media.size),
showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
contentType: state.getIn(['compose', 'content_type']),
};
}
const mapDispatchToProps = (dispatch) => ({
onChangeAdvancedOption(option, value) {
dispatch(changeComposeAdvancedOption(option, value));
},
onChangeContentType(value) {
dispatch(changeComposeContentType(value));
},
onTogglePoll() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
onDoodleOpen() {
dispatch(openModal({
modalType: 'DOODLE',
modalProps: { noEsc: true, noClose: true },
}));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Options);

View file

@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
active: state.getIn(['compose', 'poll']) !== null,
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollButton);

View file

@ -1,53 +0,0 @@
import { connect } from 'react-redux';
import {
addPollOption,
removePollOption,
changePollOption,
changePollSettings,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from '../../../actions/compose';
import PollForm from '../components/poll_form';
const mapStateToProps = state => ({
suggestions: state.getIn(['compose', 'suggestions']),
options: state.getIn(['compose', 'poll', 'options']),
lang: state.getIn(['compose', 'language']),
expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
isMultiple: state.getIn(['compose', 'poll', 'multiple']),
});
const mapDispatchToProps = dispatch => ({
onAddOption(title) {
dispatch(addPollOption(title));
},
onRemoveOption(index) {
dispatch(removePollOption(index));
},
onChangeOption(index, title) {
dispatch(changePollOption(index, title));
},
onChangeSettings(expiresIn, isMultiple) {
dispatch(changePollSettings(expiresIn, isMultiple));
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId, path) {
dispatch(selectComposeSuggestion(position, token, accountId, path));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollForm);

View file

@ -1,36 +0,0 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => {
let statusId = state.getIn(['compose', 'id'], null);
let editing = true;
if (statusId === null) {
statusId = state.getIn(['compose', 'in_reply_to']);
editing = false;
}
return {
status: getStatus(state, { id: statusId }),
editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View file

@ -42,8 +42,8 @@ const mapDispatchToProps = dispatch => ({
dispatch(showSearch());
},
onOpenURL (routerHistory) {
dispatch(openURL(routerHistory));
onOpenURL (q, routerHistory) {
dispatch(openURL(q, routerHistory));
},
onClickSearchResult (q, type) {

View file

@ -1,77 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
const messages = defineMessages({
marked: {
id: 'compose_form.sensitive.marked',
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
},
unmarked: {
id: 'compose_form.sensitive.unmarked',
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
},
});
const mapStateToProps = state => {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const spoilerText = state.getIn(['compose', 'spoiler_text']);
return {
active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
disabled: state.getIn(['compose', 'spoiler']),
mediaCount: state.getIn(['compose', 'media_attachments']).size,
};
};
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSensitivity());
},
});
class SensitiveButton extends PureComponent {
static propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
mediaCount: PropTypes.number,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { active, disabled, mediaCount, onClick, intl } = this.props;
return (
<div className='compose-form__sensitive-button'>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
<input
name='mark-sensitive'
type='checkbox'
checked={active}
onChange={onClick}
disabled={disabled}
/>
<FormattedMessage
id='compose_form.sensitive.hide'
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
values={{ count: mediaCount }}
/>
</label>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));

View file

@ -0,0 +1,32 @@
import { injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import WarningIcon from '@/material-icons/400-20px/warning.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { changeComposeSpoilerness } from '../../../actions/compose';
const messages = defineMessages({
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
});
const mapStateToProps = (state, { intl }) => ({
iconComponent: WarningIcon,
title: intl.formatMessage(state.getIn(['compose', 'spoiler']) ? messages.marked : messages.unmarked),
active: state.getIn(['compose', 'spoiler']),
ariaControls: 'cw-spoiler-input',
size: 18,
inverted: true,
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSpoilerness());
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));

View file

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

View file

@ -5,6 +5,7 @@ import Upload from '../components/upload';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
sensitive: state.getIn(['compose', 'sensitive']),
});
const mapDispatchToProps = dispatch => ({

View file

@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { me } from 'flavours/glitch/initial_state';
import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links';
import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
import Warning from '../components/warning';
@ -18,7 +17,7 @@ const mapStateToProps = state => ({
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
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={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
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> }} />} />;
}
if (hashtagWarning) {
@ -28,7 +27,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!privacyPolicyLink && <a href={privacyPolicyLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
</span>
);

View file

@ -3,93 +3,180 @@ import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { mountCompose, unmountCompose, cycleElefriendCompose } from 'flavours/glitch/actions/compose';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing-fill.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import Column from 'flavours/glitch/components/column';
import { Icon } from 'flavours/glitch/components/icon';
import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png';
import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png';
import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png';
import { logOut } from 'flavours/glitch/utils/log_out';
import elephantUIPlane from '../../../../images/elephant_ui_plane.svg';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
import { mascot } from '../../initial_state';
import { isMobile } from '../../is_mobile';
import Motion from '../ui/util/optional_motion';
import ComposeFormContainer from './containers/compose_form_container';
import HeaderContainer from './containers/header_container';
import NavigationContainer from './containers/navigation_container';
import SearchContainer from './containers/search_container';
import SearchResultsContainer from './containers/search_results_container';
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' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapStateToProps = (state, ownProps) => ({
elefriend: state.getIn(['compose', 'elefriend']),
columns: state.getIn(['settings', 'columns']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
unreadNotifications: state.getIn(['notifications', 'unread']),
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
});
const mapDispatchToProps = (dispatch) => ({
onClickElefriend () {
dispatch(cycleElefriendCompose());
},
onMount () {
dispatch(mountCompose());
},
onUnmount () {
dispatch(unmountCompose());
},
});
// ~4% chance you'll end up with an unexpected friend
// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
const glitchProbability = 1 - 0.0420215528;
const totalElefriends = 3;
class Compose extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
elefriend: PropTypes.number,
onClickElefriend: PropTypes.func,
onMount: PropTypes.func,
onUnmount: PropTypes.func,
unreadNotifications: PropTypes.number,
showNotificationsBadge: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
};
componentDidMount () {
this.props.onMount();
const { dispatch } = this.props;
dispatch(mountCompose());
}
componentWillUnmount () {
this.props.onUnmount();
const { dispatch } = this.props;
dispatch(unmountCompose());
}
handleLogoutClick = e => {
const { dispatch, intl } = this.props;
e.preventDefault();
e.stopPropagation();
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
return false;
};
handleSettingsClick = e => {
const { dispatch } = this.props;
e.preventDefault();
e.stopPropagation();
dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} }));
};
onFocus = () => {
this.props.dispatch(changeComposing(true));
};
onBlur = () => {
this.props.dispatch(changeComposing(false));
};
cycleElefriend = () => {
this.setState((state) => ({ elefriend: (state.elefriend + 1) % totalElefriends }));
};
render () {
const {
elefriend,
intl,
multiColumn,
onClickElefriend,
showSearch,
} = this.props;
const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
const { multiColumn, showSearch, showNotificationsBadge, unreadNotifications, intl } = this.props;
const elefriend = [glitchedElephant1, glitchedElephant2, glitchedElephant3, elephantUIPlane][this.state.elefriend];
if (multiColumn) {
return (
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
<HeaderContainer />
const { columns } = this.props;
{multiColumn && <SearchContainer />}
return (
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
<nav className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link>
{!columns.some(column => column.get('id') === 'HOME') && (
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></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)}>
<span className='icon-badge-wrapper'>
<Icon id='bell' icon={NotificationsIcon} />
{showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
</span>
</Link>
)}
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link>
)}
{!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link>
)}
<a
onClick={this.handleSettingsClick}
href='/settings/preferences'
className='drawer__tab'
title={intl.formatMessage(messages.settings)}
aria-label={intl.formatMessage(messages.settings)}
>
<Icon id='cogs' icon={ManufacturingIcon} />
</a>
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
{multiColumn && <SearchContainer /> }
<div className='drawer__pager'>
<div className='drawer__inner'>
<NavigationContainer />
<div className='drawer__inner' onFocus={this.onFocus}>
<ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
<ComposeFormContainer />
<div className='drawer__inner__mastodon'>
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is not a feature but a visual easter egg */}
<div className='drawer__inner__mastodon' onClick={this.cycleElefriend}>
<img alt='' draggable='false' src={mascot || elefriend} />
</div>
</div>
@ -106,8 +193,7 @@ class Compose extends PureComponent {
}
return (
<Column>
<NavigationContainer />
<Column onFocus={this.onFocus}>
<ComposeFormContainer />
<Helmet>
@ -119,4 +205,4 @@ class Compose extends PureComponent {
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Compose));
export default connect(mapStateToProps)(injectIntl(Compose));

View file

@ -45,6 +45,7 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
hidden: getAccountHidden(state, accountId),
};
};
@ -117,7 +118,7 @@ class Followers extends ImmutablePureComponent {
};
render () {
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
if (!isAccount) {
return (
@ -141,6 +142,8 @@ class Followers extends ImmutablePureComponent {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (hideCollections && accountIds.isEmpty()) {
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {

View file

@ -45,6 +45,7 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
hidden: getAccountHidden(state, accountId),
};
};
@ -117,7 +118,7 @@ class Following extends ImmutablePureComponent {
};
render () {
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
if (!isAccount) {
return (
@ -141,6 +142,8 @@ class Following extends ImmutablePureComponent {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (hideCollections && accountIds.isEmpty()) {
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {

View file

@ -32,7 +32,7 @@ import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import { me, showTrends } from '../../initial_state';
import NavigationBar from '../compose/components/navigation_bar';
import { NavigationBar } from '../compose/components/navigation_bar';
import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';

View file

@ -1,46 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import background from '@/images/friends-cropped.png';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
export const ExplorePrompt = () => (
<DismissableBanner id='home.explore_prompt'>
<img
src={background}
alt=''
className='dismissable-banner__background-image'
/>
<h1>
<FormattedMessage
id='home.explore_prompt.title'
defaultMessage='This is your home base within Mastodon.'
/>
</h1>
<p>
<FormattedMessage
id='home.explore_prompt.body'
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:"
/>
</p>
<div className='dismissable-banner__message__wrapper'>
<div className='dismissable-banner__message__actions'>
<Link to='/explore' className='button'>
<FormattedMessage
id='home.actions.go_to_explore'
defaultMessage="See what's trending"
/>
</Link>
<Link to='/explore/suggestions' className='button button-tertiary'>
<FormattedMessage
id='home.actions.go_to_suggestions'
defaultMessage='Find people to follow'
/>
</Link>
</div>
</div>
</DismissableBanner>
);

View file

@ -0,0 +1,218 @@
import PropTypes from 'prop-types';
import { useEffect, useCallback, useRef, useState } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch, useSelector } from 'react-redux';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
import { domain } from 'flavours/glitch/initial_state';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
});
const Source = ({ id }) => {
const intl = useIntl();
let label, hint;
switch (id) {
case 'friends_of_friends':
hint = intl.formatMessage(messages.friendsOfFriendsHint);
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'similar_to_recently_followed':
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'featured':
hint = intl.formatMessage(messages.featuredHint, { domain });
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
break;
case 'most_followed':
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
case 'most_interactions':
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
}
return (
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
<Icon icon={InfoIcon} />
{label}
</div>
);
};
Source.propTypes = {
id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
};
const Card = ({ id, sources }) => {
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', id]));
const relationship = useSelector(state => state.getIn(['relationships', id]));
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
const dispatch = useDispatch();
const following = relationship?.get('following') ?? relationship?.get('requested');
const handleFollow = useCallback(() => {
if (following) {
dispatch(unfollowAccount(id));
} else {
dispatch(followAccount(id));
}
}, [id, following, dispatch]);
const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion(id));
}, [id, dispatch]);
return (
<div className='inline-follow-suggestions__body__scrollable__card'>
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
</div>
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
</div>
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
</div>
);
};
Card.propTypes = {
id: PropTypes.string.isRequired,
sources: ImmutablePropTypes.list,
};
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions = ({ hidden }) => {
const intl = useIntl();
const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
const bodyRef = useRef();
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
dispatch(fetchSuggestions());
}, [dispatch]);
useEffect(() => {
if (!bodyRef.current) {
return;
}
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
const handleLeftNav = useCallback(() => {
bodyRef.current.scrollLeft -= 200;
}, [bodyRef]);
const handleRightNav = useCallback(() => {
bodyRef.current.scrollLeft += 200;
}, [bodyRef]);
const handleScroll = useCallback(() => {
if (!bodyRef.current) {
return;
}
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]);
if (dismissed || (!isLoading && suggestions.isEmpty())) {
return null;
}
if (hidden) {
return (
<div className='inline-follow-suggestions' />
);
}
return (
<div className='inline-follow-suggestions'>
<div className='inline-follow-suggestions__header'>
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
<div className='inline-follow-suggestions__header__actions'>
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
</div>
</div>
<div className='inline-follow-suggestions__body'>
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
{suggestions.map(suggestion => (
<Card
key={suggestion.get('account')}
id={suggestion.get('account')}
sources={suggestion.get('sources')}
/>
))}
</div>
{canScrollLeft && (
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
</button>
)}
{canScrollRight && (
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
</button>
)}
</div>
</div>
);
};
InlineFollowSuggestions.propTypes = {
hidden: PropTypes.bool,
};

View file

@ -6,8 +6,6 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
@ -17,7 +15,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
import { me, criticalUpdatesPending } from 'flavours/glitch/initial_state';
import { criticalUpdatesPending } from 'flavours/glitch/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';
@ -27,7 +25,6 @@ import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
@ -35,51 +32,12 @@ const messages = defineMessages({
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
});
const getHomeFeedSpeed = createSelector([
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()),
state => state.get('statuses'),
], (statusIds, pendingStatusIds, statusMap) => {
const recentStatusIds = pendingStatusIds.concat(statusIds);
const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20);
if (statuses.isEmpty()) {
return {
gap: 0,
newest: new Date(0),
};
}
const datetimes = statuses.map(status => status.get('created_at', 0));
const oldest = new Date(datetimes.min());
const newest = new Date(datetimes.max());
const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
return {
gap: averageGap,
newest,
};
});
const homeTooSlow = createSelector([
state => state.getIn(['timelines', 'home', 'isLoading']),
state => state.getIn(['timelines', 'home', 'isPartial']),
getHomeFeedSpeed,
], (isLoading, isPartial, speed) =>
!isLoading && !isPartial // Only if the home feed has finished loading
&& (
(speed.gap > (30 * 60) // If the average gap between posts is more than 30 minutes
|| (Date.now() - speed.newest) > (1000 * 3600)) // If the most recent post is from over an hour ago
)
);
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']),
tooSlow: homeTooSlow(state),
regex: state.getIn(['settings', 'home', 'regex', 'body']),
});
@ -99,7 +57,6 @@ class HomeTimeline extends PureComponent {
hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
tooSlow: PropTypes.bool,
regex: PropTypes.string,
};
@ -170,7 +127,7 @@ class HomeTimeline extends PureComponent {
};
render () {
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId;
const { signedIn } = this.context.identity;
const banners = [];
@ -194,10 +151,6 @@ class HomeTimeline extends PureComponent {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
if (tooSlow) {
banners.push(<ExplorePrompt key='explore-prompt' />);
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader

View file

@ -10,9 +10,9 @@ import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import LocalSettingsNavigationItem from './item';
const messages = defineMessages({
@ -60,7 +60,8 @@ class LocalSettingsNavigation extends PureComponent {
active={index === 2}
index={2}
onNavigate={onNavigate}
textIcon='CW'
icon='warning'
iconComponent={WarningIcon}
title={intl.formatMessage(messages.content_warnings)}
/>
<LocalSettingsNavigationItem

View file

@ -13,7 +13,6 @@ export default class LocalSettingsPage extends PureComponent {
className: PropTypes.string,
href: PropTypes.string,
icon: PropTypes.string,
textIcon: PropTypes.string,
iconComponent: PropTypes.func,
index: PropTypes.number.isRequired,
onNavigate: PropTypes.func,
@ -36,7 +35,6 @@ export default class LocalSettingsPage extends PureComponent {
href,
icon,
iconComponent,
textIcon,
onNavigate,
title,
} = this.props;
@ -45,7 +43,7 @@ export default class LocalSettingsPage extends PureComponent {
active,
}, className);
const iconElem = icon ? <Icon id={icon} icon={iconComponent} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null);
const iconElem = icon ? <Icon id={icon} icon={iconComponent} /> : null;
if (href) return (
<a

View file

@ -28,9 +28,9 @@ const messages = defineMessages({
pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
});
class LocalSettingsPage extends PureComponent {

View file

@ -95,9 +95,7 @@ class AdminReport extends ImmutablePureComponent {
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='flag' icon={FlagIcon} />
</div>
<Icon id='flag' icon={FlagIcon} />
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} />

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