Compare commits

...

233 commits

Author SHA1 Message Date
Erin Shepherd fe917bca62 Merge remote-tracking branch 'glitch-soc/main' 2023-07-05 22:59:06 +02:00
Erin Shepherd 5d54cb8d4c Sync theme updates (From upstream, & glitch patches from im-in.space) 2023-07-02 22:24:39 +02:00
Erin Shepherd fe8b6e023a Merge remote-tracking branch 'catstodon/feature/emoji_reactions' 2023-07-02 22:08:46 +02:00
Jeremy Kescher 3b3bfecba6
Fix translations 2023-05-27 14:12:04 +02:00
Jeremy Kescher fd5e5a759e
Move status_reactions.js to status_reactions.jsx 2023-05-27 13:55:17 +02:00
Plastikmensch 3dc590a327
Add missing name param.
Follow-up to 3a91f535fa
Missed these while porting changes.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 13:32:45 +02:00
Jeremy Kescher 55199ec150
Use named import for AnimatedNumber 2023-05-27 13:32:45 +02:00
Jeremy Kescher 0e1ef3efd0
Fix some RubyCop offenses 2023-05-27 13:32:43 +02:00
Plastikmensch 2a13d27be4
Fix being able to bypass MAX_REACTIONS
When reacting with different custom emojis with the same shortcode, it would count as an already present reaction and processed, bypassing the limit.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch 3a91f535fa
Refactor emoji reactions
Instead of processing tag and then look for the custom emoji, let the processing return an emoji.

Add `name` to `process_emoji_tags` to check if it matches the shortcode.

Removed `process_single_emoji` and added its code to `process_emoji_tags`

Removed arg from `maybe_process_misskey_reaction`.
Ideally, `original_status` should be a global object, but I wanted to modify vanilla code as little as possible.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch 4e15a89b39
Only allow reacting with remote emojis when status is local
Handling remote reactions with foreign emojis would require an extensive rewrite of vanilla code, so instead prevent reactions with remote emojis when the status is not local.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch ea10f2e1e0
Don't set me to true for remote reactions
When an account and a remote account reacted with a custom emoji with the same shortcode, the `me` attribute was also true for the remote reaction, despite being a different emoji.

This query should probably be optimised, but it works.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch b326dcab78
Don't allow reactions with disabled custom emojis
Also doesn't set custom_emoji to a local variant of name when not given.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch f9730eba77
Handle Undo from Misskey
Right now Misskey users were able to react, but couldn't remove their reactions.
delegates `Undo` for a `Like` to `undo_emoji_react` when there is no favourite found.

(Misskey `Like` activities can still create a fav when the emoji tag is invalid, I don't see the point though)

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch 8cecb468b0
Only process single custom emoji
Processing all custom emojis is neither wise nor necessary as both `Like` and `EmojiReact` only expect a single custom emoji

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch 239170830e
Rescue uncaught RecordInvalid errors
These occur when an account tries to react with disabled custom emojis.
In both `EmojiReact` and `Like? activities, the activity is discarded.

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Plastikmensch 051bb17de8
Add custom_emoji to reacted?
Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Jeremy Kescher 88cb32e766
ReactionsController: Don't check for status reaction existence in destroy
UnreactService checks for its existence in the background anyway, so remove redundant checks.
2023-05-27 12:01:13 +02:00
Jeremy Kescher a801d5035c
Fix invalidating status reactions when they already exist
Co-authored-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Jeremy Kescher 245e212ba1
status_reaction_fabricator: Use a unicode emoji instead of "MyString"
Co-authored-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Jeremy Kescher 241d61af52
api.rb: Remove resources line that wasn't in routes.rb anymore
Co-authored-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2023-05-27 12:01:13 +02:00
Jeremy Kescher ab5d17c9fa
Remove German translation for setting_visible_reactions 2023-05-27 12:01:13 +02:00
Jeremy Kescher 69a5c9483b
Remove old french emoji reaction-related strings 2023-05-27 12:01:13 +02:00
Jeremy Kescher 9be1967d05
Introduce visible reactions default setting 2023-05-27 12:01:13 +02:00
Jeremy Kescher 00d74e293a
Re-apply schema version 2023-05-27 12:01:13 +02:00
Jeremy Kescher 7f21afa5b8
Fix visible reactions setting not applying 2023-05-27 12:01:13 +02:00
Jeremy Kescher 956ce75185
eslint fix 2023-05-27 12:01:13 +02:00
Jeremy Kescher 4ba93c2c10
Remove further leftover makeCustomEmojiMap references 2023-05-27 12:01:13 +02:00
Jeremy Kescher d18ca9eef6
Remove duplicate notification_mailer definition 2023-05-27 12:01:13 +02:00
Jeremy Kescher 9da713f009
Fix n+1 query for move emoji reaction settings migration 2023-05-27 12:01:13 +02:00
Jeremy Kescher bf7945f15b
Run rubocop -a 2023-05-27 12:01:13 +02:00
Jeremy Kescher 263f10fd3e
Removed unused imports in status_container.js 2023-05-27 12:01:13 +02:00
Jeremy Kescher ffd8aa6a2a
Add back missing visibleReactions variable to both initial_state.js files 2023-05-27 12:01:13 +02:00
Jeremy Kescher 672c123211
Add missing visible_reactions to vanilla initial_state typedef 2023-05-27 12:01:13 +02:00
Jeremy Kescher 0859f5b511
Fix max_reactions typedef 2023-05-27 12:01:13 +02:00
Jeremy Kescher c4d82b4170
Move reaction endpoints from route.rb to api.rb 2023-05-27 12:01:13 +02:00
neatchee 20da97252d
Remove stale/missed references to makeCustomEmojiMap / EmojiMap 2023-05-27 12:01:13 +02:00
Jeremy Kescher 92fea0e028
Reactions: Return 404 when status should not be visible, asynchronous unreact 2023-05-27 12:01:13 +02:00
Jeremy Kescher 38f39b422a
Add missing authorization to ReactService 2023-05-27 12:01:13 +02:00
neatchee f08f3c9eb8
Restore loc files for non-English languages; CrowdIn should handle this 2023-05-27 12:01:13 +02:00
Jeremy Kescher 7f1b0f43e9
Update emoji reaction patches 2023-05-27 12:01:13 +02:00
neatchee 9133f5af9d
Fix placement of reactions bar for new threading UI 2023-05-27 12:01:13 +02:00
Jeremy Kescher 83e1c8e742
Migrate emoji reactions 2023-05-27 12:01:13 +02:00
neatchee 8e71bfde83
Remove old .js locale files accidentally restored during rebase 2023-05-27 12:01:13 +02:00
Ivan Rodriguez 1987dd766a
Keep emoji picker within screen bounds
Adds the `flip` prop to `<Overlay>`. Fixes #40
2023-05-27 12:01:13 +02:00
neatchee bbce42a7cb
Fix rebase issues 2023-05-27 12:01:13 +02:00
neatchee ee4e497cc6
Per PR suggestion, split name and domain, and look for emoji ID, for unreact, so remote emoji's can be unreacted 2023-05-27 12:01:13 +02:00
fef 9c69772dc6
move emoji reaction strings to locales-glitch 2023-05-27 12:01:13 +02:00
Jeremy Kescher e8c9054e74
Fix status reactions preventing an on_cascade delete 2023-05-27 12:01:13 +02:00
fef 5e46bec485
bypass reaction limit for foreign accounts 2023-05-27 12:01:13 +02:00
fef 5ec5a782d4
fix 404 when reacting with Keycap Number Sign
The Unicode sequence for this emoji starts with an
ASCII # character, which the browser's URI parser
truncates before sending the request to the
backend.
2023-05-27 12:01:13 +02:00
fef 8d9105e4c2
fix status action bar after upstream changes 2023-05-27 12:01:13 +02:00
fef 4226a5ddc8
fix schema after rebase 2023-05-27 12:01:13 +02:00
fef 6fa408f1a0
delete reaction notifications when deleting status 2023-05-27 12:01:13 +02:00
fef f87de8770b
support reacting with foreign custom emojis 2023-05-27 12:01:13 +02:00
fef e688fac3ec
properly disable reactions when not logged in 2023-05-27 12:01:13 +02:00
fef 55e741df7d
serialize custom emoji reactions properly for AP
Akkoma and possibly others expect the `tag` field
in an EmojiReact activity to be an array, not just
a single object, so it's being wrapped into one
now.  I'm not entirely sure whether this is the
idiomatic way of doing it tbh, but it works fine.
2023-05-27 12:01:13 +02:00
fef 2a64c4d028
also disable reaction buttons in vanilla flavour 2023-05-27 12:01:13 +02:00
fef 804bf4aa38
disable reaction button when not signed in 2023-05-27 12:01:13 +02:00
fef 4454aa7e99
fix image for new custom emoji reactions 2023-05-27 12:01:12 +02:00
fef 75cccfb53e
run i18n-tasks normalize 2023-05-27 12:01:12 +02:00
fef 1ca69c2513
display external custom emoji reactions properly
Using an emoji map was completely unnecessary in
the first place, because the reaction list from
the API response includes URLs for every custom
emoji anyway.  The reaction list now also contains
a boolean field indicating whether it is an
external custom emoji, which is required because
people should only be able to react with Unicode
emojis and local custom ones, not with custom
emojis from other servers.
2023-05-27 12:01:12 +02:00
fef 3df2d0b1f1
handle incoming custom emoji reactions properly 2023-05-27 12:01:12 +02:00
fef 50cd1cc5f7
support Undo action for EmojiReaction 2023-05-27 12:01:12 +02:00
fef 7500ba8102
download remote custom emojis from reactions
Emoji reactions containing custom emojis from
remote instances were assumed to already have
been downloaded and stored in the database.
This might obviously not be the case.
2023-05-27 12:01:12 +02:00
fef 3ae691ad31
sanitize setting for number of visible reactions
This is kind of a hack, but the lack of
validation for settings unfortunately makes it
necessary.
2023-05-27 12:01:12 +02:00
Jeremy Kescher e02b9efd45
Add reaction limit to instance serializer 2023-05-27 12:01:12 +02:00
fef d04be8a958
fix padding on posts without reactions
The margins of the elements above and below the
main reaction list element overlapped before
reactions were added.  Adding display: none to
empty reaction bars restores this exact look.
2023-05-27 12:01:12 +02:00
fef adc1cd4823
rename nop handler to handleNoOp
This also adds the comment in action_bar.js to
status_action_bar.js, clarifying that a future
version could improve this code by modifying
EmojiPickerDropdown.
2023-05-27 12:01:12 +02:00
fef 6ea8080771
cleanup JS imports and other minor stuff 2023-05-27 12:01:10 +02:00
fef 4690c67bdc
remove unnecessary parameter 2023-05-27 11:59:32 +02:00
fef 48ca4fa744
change reaction api to match other interactions
Status reactions had an API similar to that of
announcement reactions, using PUT and DELETE at a
single endpoint.  I believe that for statuses, it
makes more sense to follow the convention of the
other interactions and use separate POST endpoints
for create and destroy respectively.
2023-05-27 11:59:32 +02:00
fef ca89c02dec
fix reaction deletion bug and clean up controller
Turns out the strange error where it would delete
the wrong reaction occurred because I forgot to
pass the emoji name to the query, which resulted
in the database deleting the first reaction it
found.  Also, this removes the unused set_reaction
callback and includes the Authorization module for
the status reactions controller.
2023-05-27 11:59:32 +02:00
fef c80a2f0df0
remove outdated comments 2023-05-27 11:59:32 +02:00
fef 2efb74cea8
clean up new imports in vanilla flavour 2023-05-27 11:59:32 +02:00
fef a0c91c47c6
rebase with upstream 2023-05-27 11:59:32 +02:00
fef b796afc818
make number of visible reactions a vanilla setting
Reactions will be backported to the vanilla
flavour, which requires all related settings to
be accessible from the vanilla settings page
rather than the glitch specific settings modal.
2023-05-27 11:59:32 +02:00
fef f1421b6d7d
make number of displayed reactions a setting
This adds an extra item to the local settings for
specifying the number of reactions shown in toots.
The detailed status view always shows all
reactions.
2023-05-27 11:59:32 +02:00
fef 5103b384dd
change default reaction limit to 1 2023-05-27 11:59:32 +02:00
fef 0bb2d62c96
limit number of reactions displayed
Too many reactions on a single post quickly get
spammy, so they are now sorted by count and only
the first MAX_REACTIONS number of different
emojis are actually displayed.
2023-05-27 11:59:32 +02:00
fef b25d27667c
fix reaction margins and paddings 2023-05-27 11:59:32 +02:00
fef 50f91f5510
cleanup frontend emoji reaction code 2023-05-27 11:59:32 +02:00
fef 4a8b6b83aa
cleanup backend emoji reaction code 2023-05-27 11:59:32 +02:00
fef 1791024b73
fix padding for reaction button 2023-05-27 11:59:32 +02:00
fef 86e058d00e
handle misskey reactions properly
misskey federates emoji reactions as likes.
2023-05-27 11:59:32 +02:00
fef ed636f695f
move react button to action bar 2023-05-27 11:59:30 +02:00
fef f9b72463b4
cherry-pick emoji reaction changes 2023-05-27 11:58:20 +02:00
fef f1952244d1
make frontend fetch reaction limit
the maximum number of reactions was previously
hardcoded to 8.  this commit also fixes an
incorrect query in StatusReactionValidator where
it didn't count per-user reactions but the total
amount of different ones.
2023-05-27 11:58:20 +02:00
fef c369bd3154
make status reaction count limit configurable 2023-05-27 11:58:20 +02:00
fef 1bbfad0512
remove accidentally created file 2023-05-27 11:58:20 +02:00
fef 7f0e61fb8d
federate emoji reactions
this is kind of experimental, but it should work
in theory.  at least i tested it with a remove
akkoma instance and it didn't crash.
2023-05-27 11:58:20 +02:00
fef 9c7ddeedbb
show reactions in detailed status view 2023-05-27 11:58:20 +02:00
fef 44154d2c07
add frontend for emoji reactions
this is still pretty bare bones but hey, it works.
2023-05-27 11:58:20 +02:00
fef 871d6c594e
add backend support for status emoji reactions
turns out we can just reuse the code for
announcement reactions.
2023-05-27 11:58:20 +02:00
Claire 09224bb31a Disable anonymous access to the streaming API 2023-03-06 19:16:03 +01:00
Erin Shepherd 3262532ea5 Update README 2023-02-28 01:21:44 +01:00
Erin Shepherd 0e5d342c36 fix duplicate defn 2023-02-28 01:06:40 +01:00
Erin Shepherd e1bc5d252b Update locks 2023-02-28 00:54:52 +01:00
Erin Shepherd 20b4d3ccbf Bird, you're not forgotten ::<> 2023-02-28 00:45:06 +01:00
Erin Shepherd 0e9097e164 Merge remote-tracking branch 'glitch-soc/main' 2023-02-28 00:43:37 +01:00
Erin Shepherd 67687000be Merge remote-tracking branch 'neatchee/feat/emoji_reactions' 2023-02-28 00:39:43 +01:00
neatchee 1e27826472 Fix rebase issues 2023-01-26 11:32:03 -08:00
neatchee 74bad9a4cb Per PR suggestion, split name and domain, and look for emoji ID, for unreact, so remote emoji's can be unreacted 2023-01-26 10:22:15 -08:00
fef 0ac4ccfc3a move emoji reaction strings to locales-glitch 2023-01-25 13:55:05 -08:00
Jeremy Kescher 88257fe15c Fix status reactions preventing an on_cascade delete 2023-01-25 13:51:13 -08:00
fef 5173613890 bypass reaction limit for foreign accounts 2023-01-25 13:51:13 -08:00
fef c1f4e493e9 fix 404 when reacting with Keycap Number Sign
The Unicode sequence for this emoji starts with an
ASCII # character, which the browser's URI parser
truncates before sending the request to the
backend.
2023-01-25 13:51:13 -08:00
fef ac31a8d48d fix status action bar after upstream changes 2023-01-25 13:51:13 -08:00
fef 9ec39f98af fix schema after rebase 2023-01-25 13:51:13 -08:00
fef 48b00c2bdc delete reaction notifications when deleting status 2023-01-25 13:51:13 -08:00
fef e27fb1b632 support reacting with foreign custom emojis 2023-01-25 13:51:13 -08:00
fef 76a519f83e properly disable reactions when not logged in 2023-01-25 13:51:13 -08:00
fef 8304dc14a7 serialize custom emoji reactions properly for AP
Akkoma and possibly others expect the `tag` field
in an EmojiReact activity to be an array, not just
a single object, so it's being wrapped into one
now.  I'm not entirely sure whether this is the
idiomatic way of doing it tbh, but it works fine.
2023-01-25 13:51:13 -08:00
fef cbc7bc95ef also disable reaction buttons in vanilla flavour 2023-01-25 13:51:13 -08:00
fef 0eec369211 disable reaction button when not signed in 2023-01-25 13:51:13 -08:00
fef 1ad2c68912 fix image for new custom emoji reactions 2023-01-25 13:51:13 -08:00
fef 615ecb3161 run i18n-tasks normalize 2023-01-25 13:51:13 -08:00
fef 2ffa61db05 display external custom emoji reactions properly
Using an emoji map was completely unnecessary in
the first place, because the reaction list from
the API response includes URLs for every custom
emoji anyway.  The reaction list now also contains
a boolean field indicating whether it is an
external custom emoji, which is required because
people should only be able to react with Unicode
emojis and local custom ones, not with custom
emojis from other servers.
2023-01-25 13:51:10 -08:00
fef 4516cb47ac handle incoming custom emoji reactions properly 2023-01-25 13:44:21 -08:00
fef 102fbc25be support Undo action for EmojiReaction 2023-01-25 13:44:21 -08:00
fef 48a5f5f250 download remote custom emojis from reactions
Emoji reactions containing custom emojis from
remote instances were assumed to already have
been downloaded and stored in the database.
This might obviously not be the case.
2023-01-25 13:44:21 -08:00
fef 29627a4c6c fix integer cast bug
Gotta love Rails.
2023-01-25 13:44:21 -08:00
fef 59f73df49d sanitize setting for number of visible reactions
This is kind of a hack, but the lack of
validation for settings unfortunately makes it
necessary.
2023-01-25 13:44:21 -08:00
Jeremy Kescher 3431edd68b Add reaction limit to instance serializer 2023-01-25 13:44:21 -08:00
fef 0f59ce3e56 fix padding on posts without reactions
The margins of the elements above and below the
main reaction list element overlapped before
reactions were added.  Adding display: none to
empty reaction bars restores this exact look.
2023-01-25 13:44:21 -08:00
fef 3956154a16 rename nop handler to handleNoOp
This also adds the comment in action_bar.js to
status_action_bar.js, clarifying that a future
version could improve this code by modifying
EmojiPickerDropdown.
2023-01-25 13:44:21 -08:00
fef d61c47edb0 cleanup JS imports and other minor stuff 2023-01-25 13:44:20 -08:00
fef 97043dce21 remove unnecessary parameter 2023-01-25 13:43:25 -08:00
fef 3fd6173203 change reaction api to match other interactions
Status reactions had an API similar to that of
announcement reactions, using PUT and DELETE at a
single endpoint.  I believe that for statuses, it
makes more sense to follow the convention of the
other interactions and use separate POST endpoints
for create and destroy respectively.
2023-01-25 13:43:25 -08:00
fef aa76853d51 fix reaction deletion bug and clean up controller
Turns out the strange error where it would delete
the wrong reaction occurred because I forgot to
pass the emoji name to the query, which resulted
in the database deleting the first reaction it
found.  Also, this removes the unused set_reaction
callback and includes the Authorization module for
the status reactions controller.
2023-01-25 13:43:25 -08:00
fef 5e8f805447 remove outdated comments 2023-01-25 13:43:25 -08:00
fef 341c663d29 clean up new imports in vanilla flavour 2023-01-25 13:43:25 -08:00
fef b7c8a2b7b7 rebase with upstream 2023-01-25 13:43:24 -08:00
fef 6ff67a6775 make number of visible reactions a vanilla setting
Reactions will be backported to the vanilla
flavour, which requires all related settings to
be accessible from the vanilla settings page
rather than the glitch specific settings modal.
2023-01-25 13:39:00 -08:00
fef 20166444de make number of displayed reactions a setting
This adds an extra item to the local settings for
specifying the number of reactions shown in toots.
The detailed status view always shows all
reactions.
2023-01-25 13:38:59 -08:00
fef 7fc71af0cc change default reaction limit to 1 2023-01-25 13:32:37 -08:00
fef 63c03cf902 limit number of reactions displayed
Too many reactions on a single post quickly get
spammy, so they are now sorted by count and only
the first MAX_REACTIONS number of different
emojis are actually displayed.
2023-01-25 13:32:37 -08:00
fef fff8112a5f fix reaction margins and paddings 2023-01-25 13:32:37 -08:00
fef aa6abec827 cleanup frontend emoji reaction code 2023-01-25 13:32:36 -08:00
fef a88d98f7d7 cleanup backend emoji reaction code 2023-01-25 13:30:24 -08:00
fef 26972e3947 fix padding for reaction button 2023-01-25 13:30:24 -08:00
fef 79b741ea93 handle misskey reactions properly
misskey federates emoji reactions as likes.
2023-01-25 13:30:24 -08:00
fef cafc95381c move react button to action bar 2023-01-25 13:30:22 -08:00
fef 0f29c1fa8f cherry-pick emoji reaction changes 2023-01-25 13:23:43 -08:00
fef d65c974741 make frontend fetch reaction limit
the maximum number of reactions was previously
hardcoded to 8.  this commit also fixes an
incorrect query in StatusReactionValidator where
it didn't count per-user reactions but the total
amount of different ones.
2023-01-25 11:51:23 -08:00
fef 0e5bb30222 make status reaction count limit configurable 2023-01-25 11:51:23 -08:00
fef 64defa3eed remove accidentally created file 2023-01-25 11:51:23 -08:00
fef cb75d43185 federate emoji reactions
this is kind of experimental, but it should work
in theory.  at least i tested it with a remove
akkoma instance and it didn't crash.
2023-01-25 11:51:23 -08:00
fef 9958664f55 show reactions in detailed status view 2023-01-25 11:51:21 -08:00
fef 5df48a4d8a add frontend for emoji reactions
this is still pretty bare bones but hey, it works.
2023-01-25 11:47:55 -08:00
fef 9410d00d7b add backend support for status emoji reactions
turns out we can just reuse the code for
announcement reactions.
2023-01-25 11:42:52 -08:00
Erin Shepherd a5cbf6b217 queer-af skin: fiddle with font weight 2023-01-13 21:08:23 +01:00
Erin Shepherd 555867397c queer-af theme: darken highlights slightly 2023-01-10 12:21:00 +01:00
Erin Shepherd 897f82e4ff Mix in a little grey... 2023-01-09 20:52:11 +01:00
Erin Shepherd be790423ca skins: add queer-af custom 2023-01-09 20:41:25 +01:00
Erin Shepherd 4d3e044a8e Add "modern" themes 2023-01-08 15:20:47 +01:00
Erin Shepherd 8b303dcd90 [theme, im-in.space] fiddle with css slightly 2023-01-08 14:38:58 +01:00
Erin Shepherd a27c21d267 glitch: add im-in.space theme 2023-01-08 14:29:48 +01:00
Erin Shepherd d817af1d26 redo yarn.lock 2022-12-19 02:47:34 +00:00
Erin Shepherd dad4b28db9 Merge Glith PR #1980 "Add Support for Emoji Reactions"
https://github.com/glitch-soc/mastodon/pull/1980

I know it's WIP and not without issues, but it seems to be in a fairly
reasonable state already
2022-12-19 02:09:52 +00:00
Erin Shepherd a80e6a84d8 Merge remote-tracking branch 'upstream/main' 2022-12-19 02:09:33 +00:00
Jeremy Kescher e35c31114f
Fix status reactions preventing an on_cascade delete 2022-12-18 18:02:20 +01:00
fef 303cd4038a
bypass reaction limit for foreign accounts 2022-12-15 17:15:08 +01:00
fef c957eb758c
fix 404 when reacting with Keycap Number Sign
The Unicode sequence for this emoji starts with an
ASCII # character, which the browser's URI parser
truncates before sending the request to the
backend.
2022-12-11 14:29:26 +01:00
fef 1d43e6b9b0
fix status action bar after upstream changes 2022-12-09 23:08:45 +01:00
fef 74c0ec42f6
fix schema after rebase 2022-12-09 23:08:45 +01:00
fef 6e5fc00fff
delete reaction notifications when deleting status 2022-12-09 23:08:45 +01:00
fef 1cb9c9dcca
support reacting with foreign custom emojis 2022-12-09 23:08:45 +01:00
fef 66ade5c1fd
properly disable reactions when not logged in 2022-12-09 23:08:45 +01:00
fef 6da2a0d0fb
serialize custom emoji reactions properly for AP
Akkoma and possibly others expect the `tag` field
in an EmojiReact activity to be an array, not just
a single object, so it's being wrapped into one
now.  I'm not entirely sure whether this is the
idiomatic way of doing it tbh, but it works fine.
2022-12-09 23:08:44 +01:00
fef 55ba8f9c92
also disable reaction buttons in vanilla flavour 2022-12-09 23:08:44 +01:00
fef bb93649f38
disable reaction button when not signed in 2022-12-09 23:08:44 +01:00
fef e6c9206f5c
fix image for new custom emoji reactions 2022-12-09 23:08:44 +01:00
fef 7e16a2286d
run i18n-tasks normalize 2022-12-09 23:08:44 +01:00
fef 0ea02e608c
display external custom emoji reactions properly
Using an emoji map was completely unnecessary in
the first place, because the reaction list from
the API response includes URLs for every custom
emoji anyway.  The reaction list now also contains
a boolean field indicating whether it is an
external custom emoji, which is required because
people should only be able to react with Unicode
emojis and local custom ones, not with custom
emojis from other servers.
2022-12-09 23:08:44 +01:00
fef a688a0b880
handle incoming custom emoji reactions properly 2022-12-09 23:08:43 +01:00
fef e0607e36a9
support Undo action for EmojiReaction 2022-12-09 23:08:43 +01:00
fef 8dcf7b224c
download remote custom emojis from reactions
Emoji reactions containing custom emojis from
remote instances were assumed to already have
been downloaded and stored in the database.
This might obviously not be the case.
2022-12-09 23:08:43 +01:00
fef ea82a96b47
fix integer cast bug
Gotta love Rails.
2022-12-09 23:08:43 +01:00
fef 90a4c158f7
sanitize setting for number of visible reactions
This is kind of a hack, but the lack of
validation for settings unfortunately makes it
necessary.
2022-12-09 23:08:43 +01:00
Jeremy Kescher 5de3784c9b
Add reaction limit to instance serializer 2022-12-09 23:08:43 +01:00
fef bdd3c4691d
fix padding on posts without reactions
The margins of the elements above and below the
main reaction list element overlapped before
reactions were added.  Adding display: none to
empty reaction bars restores this exact look.
2022-12-09 23:08:42 +01:00
fef 6aa7d7fb12
rename nop handler to handleNoOp
This also adds the comment in action_bar.js to
status_action_bar.js, clarifying that a future
version could improve this code by modifying
EmojiPickerDropdown.
2022-12-09 23:08:42 +01:00
fef 7187d6f9cf
cleanup JS imports and other minor stuff 2022-12-09 23:08:42 +01:00
fef 14561a05c8
remove unnecessary parameter 2022-12-09 23:08:42 +01:00
fef e3f97f60a6
change reaction api to match other interactions
Status reactions had an API similar to that of
announcement reactions, using PUT and DELETE at a
single endpoint.  I believe that for statuses, it
makes more sense to follow the convention of the
other interactions and use separate POST endpoints
for create and destroy respectively.
2022-12-09 23:08:42 +01:00
fef 935788db14
fix reaction deletion bug and clean up controller
Turns out the strange error where it would delete
the wrong reaction occurred because I forgot to
pass the emoji name to the query, which resulted
in the database deleting the first reaction it
found.  Also, this removes the unused set_reaction
callback and includes the Authorization module for
the status reactions controller.
2022-12-09 23:08:42 +01:00
fef 029097a1a0
remove outdated comments 2022-12-09 23:08:41 +01:00
fef a47ecf6e69
clean up new imports in vanilla flavour 2022-12-09 23:08:41 +01:00
fef f4dbfdb9c9
rebase with upstream 2022-12-09 23:08:36 +01:00
fef be0bf21f3b
make number of visible reactions a vanilla setting
Reactions will be backported to the vanilla
flavour, which requires all related settings to
be accessible from the vanilla settings page
rather than the glitch specific settings modal.
2022-12-09 23:04:13 +01:00
fef 6d2ad83c02
make number of displayed reactions a setting
This adds an extra item to the local settings for
specifying the number of reactions shown in toots.
The detailed status view always shows all
reactions.
2022-12-09 23:04:13 +01:00
fef f535ddc445
change default reaction limit to 1 2022-12-09 23:04:13 +01:00
fef e247dd17ed
limit number of reactions displayed
Too many reactions on a single post quickly get
spammy, so they are now sorted by count and only
the first MAX_REACTIONS number of different
emojis are actually displayed.
2022-12-09 23:04:13 +01:00
fef fd885bec48
fix reaction margins and paddings 2022-12-09 23:04:13 +01:00
fef b82984f0b5
cleanup frontend emoji reaction code 2022-12-09 23:04:13 +01:00
fef 852e6ef195
cleanup backend emoji reaction code 2022-12-09 23:04:12 +01:00
fef 266bf2543d
fix padding for reaction button 2022-12-09 23:04:12 +01:00
fef 758f9f6384
handle misskey reactions properly
misskey federates emoji reactions as likes.
2022-12-09 23:04:12 +01:00
fef 8398f7ad4e
move react button to action bar 2022-12-09 23:04:12 +01:00
fef 079b0d15c5
cherry-pick emoji reaction changes 2022-12-09 23:04:12 +01:00
fef 4577711201
make frontend fetch reaction limit
the maximum number of reactions was previously
hardcoded to 8.  this commit also fixes an
incorrect query in StatusReactionValidator where
it didn't count per-user reactions but the total
amount of different ones.
2022-12-09 23:04:12 +01:00
fef 092e42a567
make status reaction count limit configurable 2022-12-09 23:04:11 +01:00
fef cacabea938
remove accidentally created file 2022-12-09 23:04:11 +01:00
fef 5b30421f3b
federate emoji reactions
this is kind of experimental, but it should work
in theory.  at least i tested it with a remove
akkoma instance and it didn't crash.
2022-12-09 23:04:11 +01:00
fef 91fcd87069
show reactions in detailed status view 2022-12-09 23:04:11 +01:00
fef a5c6f4f4b0
add frontend for emoji reactions
this is still pretty bare bones but hey, it works.
2022-12-09 23:04:11 +01:00
fef c3d4a644cf
add backend support for status emoji reactions
turns out we can just reuse the code for
announcement reactions.
2022-12-09 23:04:10 +01:00
Erin Shepherd 367d552640 Merge remote-tracking branch 'upstream/main' 2022-11-15 14:28:03 +00:00
Erin Shepherd 8e1bb28ecd Update README 2022-11-12 11:14:39 +00:00
Erin Shepherd 2e074199c8 Merge remote-tracking branch 'upstream/main' 2022-11-12 10:50:05 +00:00
Erin Shepherd 6c72698d8f overrides: apply additional violence 2022-11-01 17:37:02 +00:00
Erin Shepherd 1adc924e1c Override yarn lock 2022-11-01 17:08:59 +00:00
Erin Shepherd 673547cfa5 Nix updates 2022-11-01 16:35:01 +00:00
Erin Shepherd 5d49c2c553 Merge remote-tracking branch 'upstream/main' 2022-11-01 16:21:58 +00:00
embr 54c532cf45 Merge branch 'main' of https://github.com/glitch-soc/mastodon 2022-03-16 13:08:18 +01:00
embr e2971a743f Merge branch 'main' of https://github.com/glitch-soc/mastodon 2022-03-03 13:50:22 +01:00
embr 010cfac43e Merge branch 'main' of https://github.com/glitch-soc/mastodon 2022-02-06 17:18:51 +01:00
embr ca8e8d389d Merge branch 'main' of https://github.com/glitch-soc/mastodon 2022-02-03 14:28:58 +01:00
embr 1dd461349a status.show_less: 'Show less' -> 'Wait no' 2022-02-02 11:37:40 +01:00
embr 390c517c6f Merge branch 'main' of https://github.com/glitch-soc/mastodon 2022-02-02 11:37:26 +01:00
embr 80f9fdb8bc Merge remote-tracking branch 'glitch/main' 2021-10-18 15:11:45 +02:00
embr 2afd297091 fix custom CSS loading from port 80 in dev 2021-10-12 15:12:13 +02:00
embr 7e794b1a24 single-option polls lol 2021-10-12 15:12:13 +02:00
embr 99691e9a5a s/Toot/Honk/ 2021-10-12 15:12:13 +02:00
embr 2d3e8bf64a queer.af: change footer text and source link 2021-10-12 15:12:13 +02:00
embr 1e404025d9 add nix-shell for local development 2021-10-12 15:12:13 +02:00
embr 28287ad4aa nix: remove old version date 2021-10-12 15:12:13 +02:00
Riley Shepard 0ed8464092 Merged in new-default-avatar (pull request #2)
New custom stock avatar for new profiles
2021-10-12 15:12:13 +02:00
Riley Shepard b95609f86a Merged in favicon (pull request #1)
New custom favicons
2021-10-12 15:12:13 +02:00
embr 35ba1f089e nix: add default.nix and gemset.nix
Thank you, kind person who wrote this package with forks in mind.
2021-10-12 15:12:05 +02:00
embr 38887f24a8 nix: move kind-of from resolutions to dependencies
I'm not sure what `resolutions` actually does, but yarn2nix doesn't pick
it up there, so the build fails. Moving it to `dependencies` fixes it,
and is what upstream nixpkgs' `pkgs.mastodon` does.

Whenever upstream package.json or yarn.lock change, revert to upstream,
move all the resolutions back to dependencies, then re-run `yarn` and
`yarn2nix`.
2021-10-12 15:10:57 +02:00
embr 61fb0a9137 nix: add missing version to package.json
nix2yarn needs this, and it has to be semver-ish, or you end up in a
world of bizarre build failures.
2021-10-12 14:52:11 +02:00
119 changed files with 6064 additions and 44 deletions

View file

@ -269,6 +269,9 @@ MAX_POLL_OPTIONS=5
# Maximum allowed poll option characters
MAX_POLL_OPTION_CHARS=100
# Maximum number of emoji reactions per toot and user (minimum 1)
MAX_REACTIONS=1
# Maximum image and video/audio upload sizes
# Units are in bytes
# 1048576 bytes equals 1 megabyte

3
.gitignore vendored
View file

@ -63,3 +63,6 @@ yarn-debug.log
# Ignore Docker option files
docker-compose.override.yml
# Ignore nix build output
/result

View file

@ -1,14 +1,9 @@
# Mastodon Glitch Edition
> Now with automated deploys!
[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][code_climate]
[circleci]: https://circleci.com/gh/glitch-soc/mastodon
[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon
So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it?
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
## Updating
```bash
git pull upstream
bundix
prefetch-yarn-deps
# Update yarn hash in default.nix
```

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Api::V1::Statuses::ReactionsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
before_action :require_user!
before_action :set_status
def create
ReactService.new.call(current_account, @status, params[:id])
render json: @status, serializer: REST::StatusSerializer
end
def destroy
UnreactWorker.perform_async(current_account.id, @status.id, params[:id])
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reactions_map: { @status.id => false })
rescue Mastodon::NotPermittedError
not_found
end
private
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end

View file

@ -42,6 +42,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export const REACTION_UPDATE = 'REACTION_UPDATE';
export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST';
export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS';
export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL';
export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST';
export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS';
export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL';
export function reblog(status, visibility) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@ -393,3 +403,75 @@ export function unpinFail(status, error) {
error,
};
}
export const addReaction = (statusId, name, url) => (dispatch, getState) => {
const status = getState().get('statuses').get(statusId);
let alreadyAdded = false;
if (status) {
const reaction = status.get('reactions').find(x => x.get('name') === name);
if (reaction && reaction.get('me')) {
alreadyAdded = true;
}
}
if (!alreadyAdded) {
dispatch(addReactionRequest(statusId, name, url));
}
// encodeURIComponent is required for the Keycap Number Sign emoji, see:
// <https://github.com/glitch-soc/mastodon/pull/1980#issuecomment-1345538932>
api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => {
dispatch(addReactionSuccess(statusId, name));
}).catch(err => {
if (!alreadyAdded) {
dispatch(addReactionFail(statusId, name, err));
}
});
};
export const addReactionRequest = (statusId, name, url) => ({
type: REACTION_ADD_REQUEST,
id: statusId,
name,
url,
});
export const addReactionSuccess = (statusId, name) => ({
type: REACTION_ADD_SUCCESS,
id: statusId,
name,
});
export const addReactionFail = (statusId, name, error) => ({
type: REACTION_ADD_FAIL,
id: statusId,
name,
error,
});
export const removeReaction = (statusId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(statusId, name));
api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => {
dispatch(removeReactionSuccess(statusId, name));
}).catch(err => {
dispatch(removeReactionFail(statusId, name, err));
});
};
export const removeReactionRequest = (statusId, name) => ({
type: REACTION_REMOVE_REQUEST,
id: statusId,
name,
});
export const removeReactionSuccess = (statusId, name) => ({
type: REACTION_REMOVE_SUCCESS,
id: statusId,
name,
});
export const removeReactionFail = (statusId, name) => ({
type: REACTION_REMOVE_FAIL,
id: statusId,
name,
});

View file

@ -146,6 +146,7 @@ const excludeTypesFromFilter = filter => {
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',

View file

@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@ -12,7 +11,7 @@ import { HotKeys } from 'react-hotkeys';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import PollContainer from 'flavours/glitch/containers/poll_container';
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
import { displayMedia } from 'flavours/glitch/initial_state';
import { displayMedia, visibleReactions } from 'flavours/glitch/initial_state';
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
import Card from '../features/status/components/card';
@ -69,6 +68,7 @@ class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -86,6 +86,8 @@ class Status extends ImmutablePureComponent {
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onReactionAdd: PropTypes.func,
onReactionRemove: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
@ -751,6 +753,7 @@ class Status extends ImmutablePureComponent {
if (this.props.prepend && account) {
const notifKind = {
favourite: 'favourited',
reaction: 'reacted',
reblog: 'boosted',
reblogged_by: 'boosted',
status: 'posted',
@ -832,6 +835,15 @@ class Status extends ImmutablePureComponent {
rewriteMentions={settings.get('rewrite_mentions')}
/>
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
numVisible={visibleReactions}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
canReact={this.context.identity.signedIn}
/>
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
<StatusActionBar
status={status}

View file

@ -8,9 +8,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { me } from 'flavours/glitch/initial_state';
import { me, maxReactions } from 'flavours/glitch/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
@ -32,6 +33,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
@ -61,6 +63,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReactionAdd: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@ -117,6 +120,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
};
handleReblogClick = e => {
const { signedIn } = this.context.identity;
@ -197,10 +204,11 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onAddFilter(this.props.status);
};
handleNoOp = () => {}; // hack for reaction add button
render () {
const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
const { permissions } = this.context.identity;
const anonymousAccess = !me;
const mutingConversation = status.get('muted');
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -301,6 +309,17 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
);
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reactButton = (
<IconButton
className='status__action-bar-button'
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
title={intl.formatMessage(messages.react)}
disabled={!canReact}
icon='plus'
/>
);
return (
<div className='status__action-bar'>
<IconButton
@ -313,6 +332,12 @@ class StatusActionBar extends ImmutablePureComponent {
/>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
{
permissions
? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
: reactButton
}
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{filterButton}

View file

@ -59,6 +59,14 @@ export default class StatusPrepend extends PureComponent {
values={{ name : link }}
/>
);
case 'reaction':
return (
<FormattedMessage
id='notification.reaction'
defaultMessage='{name} reacted to your status'
values={{ name: link }}
/>
);
case 'reblog':
return (
<FormattedMessage
@ -113,6 +121,9 @@ export default class StatusPrepend extends PureComponent {
case 'favourite':
iconId = 'star';
break;
case 'reaction':
iconId = 'plus';
break;
case 'featured':
iconId = 'thumb-tack';
break;

View file

@ -0,0 +1,170 @@
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif, reduceMotion } from '../initial_state';
import spring from 'react-motion/lib/spring';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import classNames from 'classnames';
import React from 'react';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import { AnimatedNumber } from './animated_number';
import { assetHost } from '../utils/config';
export default class StatusReactions extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
numVisible: PropTypes.number,
addReaction: PropTypes.func.isRequired,
canReact: PropTypes.bool.isRequired,
removeReaction: PropTypes.func.isRequired,
};
willEnter() {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave() {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render() {
const { reactions, numVisible } = this.props;
let visibleReactions = reactions
.filter(x => x.get('count') > 0)
.sort((a, b) => b.get('count') - a.get('count'));
if (numVisible >= 0) {
visibleReactions = visibleReactions.filter((_, i) => i < numVisible);
}
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
statusId={this.props.statusId}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
canReact={this.props.canReact}
/>
))}
</div>
)}
</TransitionMotion>
);
}
}
class Reaction extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
canReact: PropTypes.bool.isRequired,
style: PropTypes.object,
};
state = {
hovered: false,
};
handleClick = () => {
const { reaction, statusId, addReaction, removeReaction } = this.props;
if (reaction.get('me')) {
removeReaction(statusId, reaction.get('name'));
} else {
addReaction(statusId, reaction.get('name'));
}
}
handleMouseEnter = () => this.setState({ hovered: true })
handleMouseLeave = () => this.setState({ hovered: false })
render() {
const { reaction } = this.props;
return (
<button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
disabled={!this.props.canReact}
style={this.props.style}
>
<span className='reactions-bar__item__emoji'>
<Emoji
hovered={this.state.hovered}
emoji={reaction.get('name')}
url={reaction.get('url')}
staticUrl={reaction.get('static_url')}
/>
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.get('count')} />
</span>
</button>
);
}
}
class Emoji extends React.PureComponent {
static propTypes = {
emoji: PropTypes.string.isRequired,
hovered: PropTypes.bool.isRequired,
url: PropTypes.string,
staticUrl: PropTypes.string,
};
render() {
const { emoji, hovered, url, staticUrl } = this.props;
if (unicodeMapping[emoji]) {
const { filename, shortCode } = unicodeMapping[this.props.emoji];
const title = shortCode ? `:${shortCode}:` : '';
return (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else {
const filename = (autoPlayGif || hovered) ? url : staticUrl;
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
}
}
}

View file

@ -21,6 +21,8 @@ import {
unbookmark,
pin,
unpin,
addReaction,
removeReaction,
} from 'flavours/glitch/actions/interactions';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { openModal } from 'flavours/glitch/actions/modal';
@ -173,6 +175,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onReactionAdd (statusId, name, url) {
dispatch(addReaction(statusId, name, url));
},
onReactionRemove (statusId, name) {
dispatch(removeReaction(statusId, name));
},
onEmbed (status) {
dispatch(openModal({
modalType: 'EMBED',

View file

@ -324,6 +324,7 @@ class EmojiPickerDropdown extends PureComponent {
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
disabled: PropTypes.bool,
};
state = {
@ -357,7 +358,7 @@ class EmojiPickerDropdown extends PureComponent {
};
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {
@ -395,7 +396,7 @@ class EmojiPickerDropdown extends PureComponent {
/>}
</div>
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
<Overlay show={active} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, placement })=> (
<div {...props} style={{ ...props.style, width: 299 }}>
<div className={`dropdown-animation ${placement}`}>

View file

@ -120,6 +120,17 @@ export default class ColumnSettings extends PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span>
<div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />}
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>

View file

@ -8,6 +8,7 @@ import { Icon } from 'flavours/glitch/components/icon';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
@ -75,6 +76,13 @@ class FilterBar extends PureComponent {
>
<Icon id='star' fixedWidth />
</button>
<button
className={selectedFilter === 'reaction' ? 'active' : ''}
onClick={this.onClick('reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='plus' fixedWidth />
</button>
<button
className={selectedFilter === 'reblog' ? 'active' : ''}
onClick={this.onClick('reblog')}

View file

@ -159,6 +159,28 @@ export default class Notification extends ImmutablePureComponent {
unread={this.props.unread}
/>
);
case 'reaction':
return (
<StatusContainer
containerId={notification.get('id')}
hidden={hidden}
id={notification.get('status')}
account={notification.get('account')}
prepend='reaction'
muted
notification={notification}
onMoveDown={onMoveDown}
onMoveUp={onMoveUp}
onMention={onMention}
getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
onUnmount={this.props.onUnmount}
withDismiss
unread={this.props.unread}
/>
);
case 'reblog':
return (
<StatusContainer

View file

@ -9,9 +9,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { me } from 'flavours/glitch/initial_state';
import { me, maxReactions } from 'flavours/glitch/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -25,6 +26,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
@ -55,6 +57,7 @@ class ActionBar extends PureComponent {
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onReactionAdd: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onMute: PropTypes.func,
onMuteConversation: PropTypes.func,
@ -81,6 +84,10 @@ class ActionBar extends PureComponent {
this.props.onFavourite(this.props.status, e);
};
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
}
handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e);
};
@ -140,6 +147,8 @@ class ActionBar extends PureComponent {
navigator.clipboard.writeText(url);
};
handleNoOp = () => {} // hack for reaction add button
render () {
const { status, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -202,6 +211,21 @@ class ActionBar extends PureComponent {
}
}
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reactButton = (
<IconButton
className='plus-icon'
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
title={intl.formatMessage(messages.react)}
disabled={!canReact}
icon='plus'
/>
);
const shareButton = ('share' in navigator) && publicStatus && (
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
);
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle;
@ -220,6 +244,14 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'>
{
signedIn
? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
: reactButton
}
</div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'>

View file

@ -21,6 +21,7 @@ import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
import PollContainer from 'flavours/glitch/containers/poll_container';
import Audio from 'flavours/glitch/features/audio';
import Video from 'flavours/glitch/features/video';
import StatusReactions from 'flavours/glitch/components/status_reactions';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
@ -33,6 +34,7 @@ class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -53,6 +55,8 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onReactionAdd: PropTypes.func.isRequired,
onReactionRemove: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -332,6 +336,14 @@ class DetailedStatus extends ImmutablePureComponent {
disabled
/>
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
canReact={this.context.identity.signedIn}
/>
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />

View file

@ -29,6 +29,8 @@ import {
unreblog,
pin,
unpin,
addReaction,
removeReaction,
} from 'flavours/glitch/actions/interactions';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { openModal } from 'flavours/glitch/actions/modal';
@ -311,6 +313,19 @@ class Status extends ImmutablePureComponent {
}
};
handleReactionAdd = (statusId, name, url) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
dispatch(addReaction(statusId, name, url));
}
};
handleReactionRemove = (statusId, name) => {
this.props.dispatch(removeReaction(statusId, name));
};
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
@ -717,6 +732,8 @@ class Status extends ImmutablePureComponent {
settings={settings}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
onReactionAdd={this.handleReactionAdd}
onReactionRemove={this.handleReactionRemove}
expanded={isExpanded}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
@ -731,6 +748,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReactionAdd={this.handleReactionAdd}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}

View file

@ -88,7 +88,7 @@ class LinkFooter extends PureComponent {
</p>
<p>
<strong>Mastodon</strong>:
<strong>Mastodon, queer.af edition</strong>:
{' '}
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
{DividingCircle}

View file

@ -81,6 +81,7 @@
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
* @property {string} version
* @property {number} visible_reactions
* @property {boolean} translation_enabled
* @property {string} status_page_url
* @property {boolean} system_emoji_font
@ -95,6 +96,7 @@
* @property {object} local_settings
* @property {number} max_toot_chars
* @property {number} poll_limits
* @property {number} max_reactions
*/
const element = document.getElementById('initial-state');
@ -131,6 +133,7 @@ export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const maxReactions = (initialState && initialState.max_reactions) || 1;
export const me = getMeta('me');
export const movedToAccountId = getMeta('moved_to_account_id');
export const owner = getMeta('owner');
@ -149,6 +152,7 @@ export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const visibleReactions = getMeta('visible_reactions');
export const languages = initialState?.languages;
export const statusPageUrl = getMeta('status_page_url');

View file

@ -0,0 +1,12 @@
import inherited from 'mastodon/locales/de.json';
const messages = {
'notification.reaction': '{name} hat auf deinen Beitrag reagiert',
'notifications.column_settings.reaction': 'Reaktionen:',
'tooltips.reactions': 'Reaktionen',
'status.react': 'Reagieren',
};
export default Object.assign({}, inherited, messages);

View file

@ -61,6 +61,7 @@
"keyboard_shortcuts.bookmark": "zu Lesezeichen hinzufügen",
"keyboard_shortcuts.secondary_toot": "Toot mit sekundärer Privatsphäreeinstellung absenden",
"keyboard_shortcuts.toggle_collapse": "Toots ein-/ausklappen",
"tooltips.reactions": "Reaktionen",
"layout.auto": "Automatisch",
"layout.desktop": "Desktop",
"layout.hint.auto": "Automatisch das Layout anhand der Einstellung \"Erweitertes Webinterface verwenden\" und Bildschirmgröße auswählen.",
@ -74,6 +75,8 @@
"navigation_bar.keyboard_shortcuts": "Tastaturkürzel",
"navigation_bar.misc": "Sonstiges",
"notification.markForDeletion": "Zum Entfernen auswählen",
"notification.reaction": "{name} hat auf deinen Beitrag reagiert",
"notifications.column_settings.reaction": "Reaktionen:",
"notification_purge.btn_all": "Alle\nauswählen",
"notification_purge.btn_apply": "Ausgewählte\nentfernen",
"notification_purge.btn_invert": "Auswahl\numkehren",
@ -133,6 +136,7 @@
"settings.deprecated_setting": "Diese Einstellung wird nun von Mastodons {settings_page_link} gesteuert",
"settings.enable_collapsed": "Eingeklappte Toots aktivieren",
"settings.enable_collapsed_hint": "Eingeklappte Posts haben einen Teil ihres Inhalts verborgen, um weniger Platz am Bildschirm einzunehmen. Das passiert unabhängig von der Inhaltswarnfunktion",
"settings.enter_amount_prompt": "Gib eine Zahl ein",
"settings.enable_content_warnings_auto_unfold": "Inhaltswarnungen automatisch ausklappen",
"settings.general": "Allgemein",
"settings.hicolor_privacy_icons": "Eingefärbte Privatsphäre-Symbole",
@ -146,6 +150,7 @@
"settings.layout_opts": "Layout-Optionen",
"settings.media": "Medien",
"settings.media_fullwidth": "Medienvorschau in voller Breite",
"settings.num_visible_reactions": "Anzahl sichtbarer Reaktionen",
"settings.media_letterbox": "Mediengröße anpassen",
"settings.media_letterbox_hint": "Medien runterskalieren und einpassen um die Bildbehälter zu füllen anstatt zu strecken und zuzuschneiden",
"settings.media_reveal_behind_cw": "Empfindliche Medien hinter Inhaltswarnungen standardmäßig anzeigen",
@ -188,6 +193,7 @@
"settings.wide_view": "Breite Ansicht (nur für den Desktop-Modus)",
"settings.wide_view_hint": "Verbreitert Spalten, um den verfügbaren Platz besser zu füllen.",
"status.collapse": "Einklappen",
"status.react": "Reagieren",
"status.has_audio": "Hat angehängte Audiodateien",
"status.has_pictures": "Hat angehängte Bilder",
"status.has_preview_card": "Hat eine Vorschaukarte",

View file

@ -0,0 +1,197 @@
import inherited from 'mastodon/locales/en.json';
const messages = {
'getting_started.open_source_notice': 'Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.',
'layout.auto': 'Auto',
'layout.current_is': 'Your current layout is:',
'layout.desktop': 'Desktop',
'layout.single': 'Mobile',
'layout.hint.auto': 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.',
'layout.hint.desktop': 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.',
'layout.hint.single': 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.',
'navigation_bar.app_settings': 'App settings',
'navigation_bar.misc': 'Misc',
'navigation_bar.keyboard_shortcuts': 'Keyboard shortcuts',
'navigation_bar.info': 'Extended information',
'navigation_bar.featured_users': 'Featured users',
'getting_started.onboarding': 'Show me around',
'onboarding.next': 'Next',
'onboarding.done': 'Done',
'onboarding.skip': 'Skip',
'onboarding.page_one.federation': '{domain} is an \'instance\' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.',
'onboarding.page_one.welcome': 'Welcome to {domain}!',
'onboarding.page_one.handle': 'You are on {domain}, so your full handle is {handle}',
'onboarding.page_two.compose': 'Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.',
'onboarding.page_three.search': 'Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.',
'onboarding.page_three.profile': 'Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.',
'onboarding.page_four.home': 'The home timeline shows posts from people you follow.',
'onboarding.page_four.notifications': 'The notifications column shows when someone interacts with you.',
'onboarding.page_five.public_timelines': 'The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.',
'onboarding.page_six.admin': 'Your instance\'s admin is {admin}.',
'onboarding.page_six.read_guidelines': 'Please read {domain}\'s {guidelines}!',
'onboarding.page_six.guidelines': 'community guidelines',
'onboarding.page_six.almost_done': 'Almost done...',
'onboarding.page_six.github': '{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.',
'onboarding.page_six.apps_available': 'There are {apps} available for iOS, Android and other platforms.',
'onboarding.page_six.various_app': 'mobile apps',
'onboarding.page_six.appetoot': 'Bon Appetoot!',
'settings.auto_collapse': 'Automatic collapsing',
'settings.auto_collapse_all': 'Everything',
'settings.auto_collapse_lengthy': 'Lengthy toots',
'settings.auto_collapse_media': 'Toots with media',
'settings.auto_collapse_notifications': 'Notifications',
'settings.auto_collapse_reblogs': 'Boosts',
'settings.auto_collapse_replies': 'Replies',
'settings.show_action_bar': 'Show action buttons in collapsed toots',
'settings.close': 'Close',
'settings.collapsed_statuses': 'Collapsed toots',
'settings.enable_collapsed': 'Enable collapsed toots',
'settings.enable_collapsed_hint': 'Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature',
'settings.general': 'General',
'settings.compose_box_opts': 'Compose box',
'settings.side_arm': 'Secondary toot button:',
'settings.side_arm.none': 'None',
'settings.side_arm_reply_mode': 'When replying to a toot, the secondary toot button should:',
'settings.side_arm_reply_mode.keep': 'Keep its set privacy',
'settings.side_arm_reply_mode.copy': 'Copy privacy setting of the toot being replied to',
'settings.side_arm_reply_mode.restrict': 'Restrict privacy setting to that of the toot being replied to',
'settings.always_show_spoilers_field': 'Always enable the Content Warning field',
'settings.prepend_cw_re': 'Prepend “re: ” to content warnings when replying',
'settings.preselect_on_reply': 'Pre-select usernames on reply',
'settings.preselect_on_reply_hint': 'When replying to a conversation with multiple participants, pre-select usernames past the first',
'settings.confirm_missing_media_description': 'Show confirmation dialog before sending toots lacking media descriptions',
'settings.confirm_before_clearing_draft': 'Show confirmation dialog before overwriting the message being composed',
'settings.show_content_type_choice': 'Show content-type choice when authoring toots',
'settings.content_warnings': 'Content Warnings',
'settings.content_warnings.regexp': 'Regular expression',
'settings.content_warnings_shared_state': 'Show/hide content of all copies at once',
'settings.content_warnings_shared_state_hint': 'Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW',
'settings.content_warnings_media_outside': 'Display media attachments outside content warnings',
'settings.content_warnings_media_outside_hint': 'Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments',
'settings.content_warnings_unfold_opts': 'Auto-unfolding options',
'settings.enable_content_warnings_auto_unfold': 'Automatically unfold content-warnings',
'settings.deprecated_setting': 'This setting is now controlled from Mastodon\'s {settings_page_link}',
'settings.shared_settings_link': 'user preferences',
'settings.content_warnings_filter': 'Content warnings to not automatically unfold:',
'settings.layout_opts': 'Layout options',
'settings.rewrite_mentions_no': 'Do not rewrite mentions',
'settings.rewrite_mentions_acct': 'Rewrite with username and domain (when the account is remote)',
'settings.rewrite_mentions_username': 'Rewrite with username',
'settings.show_reply_counter': 'Display an estimate of the reply count',
'settings.hicolor_privacy_icons': 'High color privacy icons',
'settings.hicolor_privacy_icons.hint': 'Display privacy icons in bright and easily distinguishable colors',
'settings.confirm_boost_missing_media_description': 'Show confirmation dialog before boosting toots lacking media descriptions',
'settings.tag_misleading_links': 'Tag misleading links',
'settings.tag_misleading_links.hint': 'Add a visual indication with the link target host to every link not mentioning it explicitly',
'settings.rewrite_mentions': 'Rewrite mentions in displayed statuses',
'settings.notifications_opts': 'Notifications options',
'settings.notifications.tab_badge': 'Unread notifications badge',
'settings.notifications.tab_badge.hint': 'Display a badge for unread notifications in the column icons when the notifications column isn\'t open',
'settings.notifications.favicon_badge': 'Unread notifications favicon badge',
'settings.notifications.favicon_badge.hint': 'Add a badge for unread notifications to the favicon',
'settings.status_icons': 'Toot icons',
'settings.status_icons_language': 'Language indicator',
'settings.status_icons_reply': 'Reply indicator',
'settings.status_icons_local_only': 'Local-only indicator',
'settings.status_icons_media': 'Media and poll indicators',
'settings.status_icons_visibility': 'Toot privacy indicator',
'settings.layout': 'Layout:',
'settings.image_backgrounds': 'Image backgrounds',
'settings.image_backgrounds_media': 'Preview collapsed toot media',
'settings.image_backgrounds_media_hint': 'If the post has any media attachment, use the first one as a background',
'settings.image_backgrounds_users': 'Give collapsed toots an image background',
'settings.media': 'Media',
'settings.media_letterbox': 'Letterbox media',
'settings.media_letterbox_hint': 'Scale down and letterbox media to fill the image containers instead of stretching and cropping them',
'settings.media_fullwidth': 'Full-width media previews',
'settings.inline_preview_cards': 'Inline preview cards for external links',
'settings.media_reveal_behind_cw': 'Reveal sensitive media behind a CW by default',
'settings.pop_in_player': 'Enable pop-in player',
'settings.pop_in_position': 'Pop-in player position:',
'settings.pop_in_left': 'Left',
'settings.pop_in_right': 'Right',
'settings.preferences': 'User preferences',
'settings.wide_view': 'Wide view (Desktop mode only)',
'settings.wide_view_hint': 'Stretches columns to better fill the available space.',
'settings.navbar_under': 'Navbar at the bottom (Mobile only)',
'status.collapse': 'Collapse',
'status.react': 'React',
'status.uncollapse': 'Uncollapse',
'status.in_reply_to': 'This toot is a reply',
'status.has_preview_card': 'Features an attached preview card',
'status.has_pictures': 'Features attached pictures',
'status.is_poll': 'This toot is a poll',
'status.has_video': 'Features attached videos',
'status.has_audio': 'Features attached audio files',
'status.local_only': 'Only visible from your instance',
'content_type.change': 'Content type',
'compose.content-type.html': 'HTML',
'compose.content-type.markdown': 'Markdown',
'compose.content-type.plain': 'Plain text',
'compose_form.poll.single_choice': 'Allow one choice',
'compose_form.poll.multiple_choices': 'Allow multiple choices',
'compose_form.spoiler': 'Hide text behind warning',
'column.toot': 'Toots and replies',
'column_header.profile': 'Profile',
'column.heading': 'Misc',
'column.subheading': 'Miscellaneous options',
'column_subheading.navigation': 'Navigation',
'column_subheading.lists': 'Lists',
'media_gallery.sensitive': 'Sensitive',
'favourite_modal.combo': 'You can press {combo} to skip this next time',
'home.column_settings.show_direct': 'Show DMs',
'notification.markForDeletion': 'Mark for deletion',
'notification.reaction': '{name} reacted to your post',
'notifications.clear': 'Clear all my notifications',
'notifications.column_settings.reaction': 'Reactions:',
'notifications.marked_clear_confirmation': 'Are you sure you want to permanently clear all selected notifications?',
'notifications.marked_clear': 'Clear selected notifications',
'notification_purge.start': 'Enter notification cleaning mode',
'notification_purge.btn_all': 'Select\nall',
'notification_purge.btn_none': 'Select\nnone',
'notification_purge.btn_invert': 'Invert\nselection',
'notification_purge.btn_apply': 'Clear\nselected',
'compose.attach.upload': 'Upload a file',
'compose.attach.doodle': 'Draw something',
'compose.attach': 'Attach...',
'advanced_options.local-only.short': 'Local-only',
'advanced_options.local-only.long': 'Do not post to other instances',
'advanced_options.local-only.tooltip': 'This post is local-only',
'advanced_options.icon_title': 'Advanced options',
'advanced_options.threaded_mode.short': 'Threaded mode',
'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting',
'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled',
'endorsed_accounts_editor.endorsed_accounts': 'Featured accounts',
'account.add_account_note': 'Add note for @{name}',
'account_note.cancel': 'Cancel',
'account_note.save': 'Save',
'account_note.edit': 'Edit',
'account_note.glitch_placeholder': 'No comment provided',
'account.joined': 'Joined {date}',
'account.follows': 'Follows',
'home.column_settings.advanced': 'Advanced',
'home.column_settings.filter_regex': 'Filter out by regular expressions',
'direct.group_by_conversations': 'Group by conversation',
'community.column_settings.allow_local_only': 'Show local-only toots',
'keyboard_shortcuts.bookmark': 'to bookmark',
'keyboard_shortcuts.toggle_collapse': 'to collapse/uncollapse toots',
'keyboard_shortcuts.secondary_toot': 'to send toot using secondary privacy setting',
'tooltips.reactions': 'Reactions',
};
export default Object.assign({}, inherited, messages);

View file

@ -77,11 +77,14 @@
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
"navigation_bar.misc": "Misc",
"notification.markForDeletion": "Mark for deletion",
"notification.reaction": "{name} reacted to your post",
"notification_purge.btn_all": "Select\nall",
"notification_purge.btn_apply": "Clear\nselected",
"notification_purge.btn_invert": "Invert\nselection",
"notification_purge.btn_none": "Select\nnone",
"notification_purge.start": "Enter notification cleaning mode",
"notifications.column_settings.reaction": "Reactions:",
"notifications.filter.reactions": "Reactions",
"notifications.marked_clear": "Clear selected notifications",
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
"onboarding.done": "Done",
@ -198,6 +201,7 @@
"status.in_reply_to": "This toot is a reply",
"status.is_poll": "This toot is a poll",
"status.local_only": "Only visible from your instance",
"status.react": "React",
"status.sensitive_toggle": "Click to view",
"status.uncollapse": "Uncollapse",
"web_app_crash.change_your_settings": "Change your {settings}",

View file

@ -0,0 +1,12 @@
import inherited from 'mastodon/locales/fr.json';
const messages = {
'notification.reaction': '{name} a réagi·e à votre message',
'notifications.column_settings.reaction': 'Réactions:',
'tooltips.reactions': 'Réactions',
'status.react': 'Réagir',
};
export default Object.assign({}, inherited, messages);

View file

@ -61,6 +61,7 @@
"keyboard_shortcuts.bookmark": "ajouter aux marque-pages",
"keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
"keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
"tooltips.reactions": "Réactions",
"layout.auto": "Auto",
"layout.desktop": "Ordinateur",
"layout.hint.auto": "Choisir automatiquement la mise en page selon l'option \"Activer l'interface Web avancée\" et la taille d'écran.",
@ -74,6 +75,8 @@
"navigation_bar.keyboard_shortcuts": "Raccourcis clavier",
"navigation_bar.misc": "Autres",
"notification.markForDeletion": "Ajouter aux éléments à supprimer",
"notification.reaction": "{name} a réagi·e à votre message",
"notifications.column_settings.reaction": "Réactions:",
"notification_purge.btn_all": "Sélectionner\ntout",
"notification_purge.btn_apply": "Effacer\nla sélection",
"notification_purge.btn_invert": "Inverser\nla sélection",
@ -126,6 +129,7 @@
"settings.deprecated_setting": "Cette option est maintenant définie par les {settings_page_link} de Mastodon",
"settings.enable_collapsed": "Activer le repliement des posts",
"settings.enable_collapsed_hint": "Les posts repliés ont une partie de leur contenu caché pour libérer de l'espace sur l'écran. C'est une option différente de l'avertissement de contenu",
"settings.enter_amount_prompt": "Entrez un montant",
"settings.enable_content_warnings_auto_unfold": "Déplier automatiquement les avertissements de contenu",
"settings.general": "Général",
"settings.hicolor_privacy_icons": "Indicateurs de confidentialité en couleurs",
@ -139,6 +143,7 @@
"settings.layout_opts": "Mise en page",
"settings.media": "Média",
"settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
"settings.num_visible_reactions": "Nombre de réactions visibles",
"settings.media_letterbox": "Afficher les médias en Letterbox",
"settings.media_letterbox_hint": "Réduit le média et utilise une letterbox pour afficher l'image entière plutôt que de l'étirer et de la rogner",
"settings.media_reveal_behind_cw": "Toujours afficher les médias sensibles avec avertissement",
@ -189,6 +194,7 @@
"status.is_poll": "Ce post est un sondage",
"status.local_only": "Visible uniquement depuis votre instance",
"status.sensitive_toggle": "Cliquer pour voir",
"status.react": "Réagir",
"status.uncollapse": "Déplier",
"web_app_crash.change_your_settings": "Changez vos {settings}",
"web_app_crash.content": "Voici les différentes options qui s'offrent à vous :",

View file

@ -39,6 +39,7 @@ const initialState = ImmutableMap({
follow: false,
follow_request: false,
favourite: false,
reaction: false,
reblog: false,
mention: false,
poll: false,
@ -62,6 +63,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,
@ -75,6 +77,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,

View file

@ -8,6 +8,11 @@ import {
UNFAVOURITE_SUCCESS,
BOOKMARK_REQUEST,
BOOKMARK_FAIL,
REACTION_UPDATE,
REACTION_ADD_FAIL,
REACTION_REMOVE_FAIL,
REACTION_ADD_REQUEST,
REACTION_REMOVE_REQUEST,
} from 'flavours/glitch/actions/interactions';
import {
STATUS_MUTE_SUCCESS,
@ -40,6 +45,43 @@ const deleteStatus = (state, id, references) => {
return state.delete(id);
};
const updateReaction = (state, id, name, updater) => state.update(
id,
status => status.update(
'reactions',
reactions => {
const index = reactions.findIndex(reaction => reaction.get('name') === name);
if (index > -1) {
return reactions.update(index, reaction => updater(reaction));
} else {
return reactions.push(updater(fromJS({ name, count: 0 })));
}
},
),
);
const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count));
// The url parameter is only used when adding a new custom emoji reaction
// (one that wasn't in the reactions list before) because we don't have its
// URL yet. In all other cases, it's undefined.
const addReaction = (state, id, name, url) => updateReaction(
state,
id,
name,
x => x.set('me', true)
.update('count', n => n + 1)
.update('url', old => old ? old : url)
.update('static_url', old => old ? old : url),
);
const removeReaction = (state, id, name) => updateReaction(
state,
id,
name,
x => x.set('me', false).update('count', n => n - 1),
);
const statusTranslateSuccess = (state, id, translation) => {
return state.withMutations(map => {
map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id))));
@ -87,6 +129,14 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false);
case REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case REACTION_ADD_REQUEST:
case REACTION_REMOVE_FAIL:
return addReaction(state, action.id, action.name, action.url);
case REACTION_REMOVE_REQUEST:
case REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name);
case STATUS_MUTE_SUCCESS:
return state.setIn([action.id, 'muted'], true);
case STATUS_UNMUTE_SUCCESS:

View file

@ -315,6 +315,10 @@
text-align: center;
}
.detailed-status__button .emoji-button {
padding: 0;
}
.relationship-tag {
color: $primary-text-color;
margin-bottom: 4px;

View file

@ -380,6 +380,10 @@
.notification__message {
margin: -10px 0 10px;
}
.reactions-bar--empty {
display: none;
}
}
.notification-favourite {
@ -524,6 +528,10 @@
align-items: center;
display: flex;
margin-top: 8px;
& > .emoji-picker-dropdown > .emoji-button {
padding: 0;
}
}
.status__action-bar-button {
@ -532,6 +540,14 @@
&.icon-button--with-counter {
margin-inline-end: 14px;
}
.fa-plus {
padding-top: 1px;
}
.fa-plus {
padding-top: 1px;
}
}
.status__action-bar-dropdown {
@ -599,6 +615,10 @@
display: flex;
flex-direction: row;
padding: 10px 0;
.fa-plus {
padding-top: 2px;
}
}
.detailed-status__link {
@ -1039,7 +1059,8 @@ a.status-card.compact:hover {
border-bottom: 0;
.status__content,
.status__action-bar {
.status__action-bar,
.reactions-bar {
margin-inline-start: 46px + 10px;
width: calc(100% - (46px + 10px));
}

View file

@ -42,6 +42,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export const REACTION_UPDATE = 'REACTION_UPDATE';
export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST';
export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS';
export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL';
export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST';
export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS';
export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL';
export function reblog(status, visibility) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@ -413,3 +423,75 @@ export function unpinFail(status, error) {
skipLoading: true,
};
}
export const addReaction = (statusId, name, url) => (dispatch, getState) => {
const status = getState().get('statuses').get(statusId);
let alreadyAdded = false;
if (status) {
const reaction = status.get('reactions').find(x => x.get('name') === name);
if (reaction && reaction.get('me')) {
alreadyAdded = true;
}
}
if (!alreadyAdded) {
dispatch(addReactionRequest(statusId, name, url));
}
// encodeURIComponent is required for the Keycap Number Sign emoji, see:
// <https://github.com/glitch-soc/mastodon/pull/1980#issuecomment-1345538932>
api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => {
dispatch(addReactionSuccess(statusId, name));
}).catch(err => {
if (!alreadyAdded) {
dispatch(addReactionFail(statusId, name, err));
}
});
};
export const addReactionRequest = (statusId, name, url) => ({
type: REACTION_ADD_REQUEST,
id: statusId,
name,
url,
});
export const addReactionSuccess = (statusId, name) => ({
type: REACTION_ADD_SUCCESS,
id: statusId,
name,
});
export const addReactionFail = (statusId, name, error) => ({
type: REACTION_ADD_FAIL,
id: statusId,
name,
error,
});
export const removeReaction = (statusId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(statusId, name));
api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => {
dispatch(removeReactionSuccess(statusId, name));
}).catch(err => {
dispatch(removeReactionFail(statusId, name, err));
});
};
export const removeReactionRequest = (statusId, name) => ({
type: REACTION_REMOVE_REQUEST,
id: statusId,
name,
});
export const removeReactionSuccess = (statusId, name) => ({
type: REACTION_REMOVE_SUCCESS,
id: statusId,
name,
});
export const removeReactionFail = (statusId, name) => ({
type: REACTION_REMOVE_FAIL,
id: statusId,
name,
});

View file

@ -131,6 +131,7 @@ const excludeTypesFromFilter = filter => {
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',

View file

@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@ -17,7 +16,7 @@ import Card from '../features/status/components/card';
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { displayMedia } from '../initial_state';
import { displayMedia, visibleReactions } from '../initial_state';
import AttachmentList from './attachment_list';
import { Avatar } from './avatar';
@ -26,6 +25,7 @@ import { DisplayName } from './display_name';
import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import StatusReactions from './status_reactions';
const domParser = new DOMParser();
@ -74,6 +74,7 @@ class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -89,6 +90,8 @@ class Status extends ImmutablePureComponent {
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onReactionAdd: PropTypes.func,
onReactionRemove: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
@ -112,6 +115,7 @@ class Status extends ImmutablePureComponent {
cachedMediaWidth: PropTypes.number,
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
emojiMap: ImmutablePropTypes.map.isRequired,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
@ -570,6 +574,15 @@ class Status extends ImmutablePureComponent {
{media}
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
numVisible={visibleReactions}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
canReact={this.context.identity.signedIn}
/>
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>
</div>

View file

@ -1,7 +1,6 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -9,9 +8,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { me } from '../initial_state';
import { me, maxReactions } from '../initial_state';
import { IconButton } from './icon_button';
@ -32,6 +32,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
@ -70,6 +71,7 @@ class StatusActionBar extends ImmutablePureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReactionAdd: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@ -130,6 +132,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
}
handleReblogClick = e => {
const { signedIn } = this.context.identity;
@ -233,6 +239,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onFilter();
};
handleNoOp = () => {} // hack for reaction add button
render () {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -359,11 +367,27 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
);
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reactButton = (
<IconButton
className='status__action-bar-button'
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
title={intl.formatMessage(messages.react)}
disabled={!canReact}
icon='plus'
/>
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
{
signedIn
? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
: reactButton
}
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{filterButton}

View file

@ -0,0 +1,170 @@
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif, reduceMotion } from '../initial_state';
import spring from 'react-motion/lib/spring';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import classNames from 'classnames';
import React from 'react';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import { AnimatedNumber } from './animated_number';
import { assetHost } from '../utils/config';
export default class StatusReactions extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
numVisible: PropTypes.number,
addReaction: PropTypes.func.isRequired,
canReact: PropTypes.bool.isRequired,
removeReaction: PropTypes.func.isRequired,
};
willEnter() {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave() {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render() {
const { reactions, numVisible } = this.props;
let visibleReactions = reactions
.filter(x => x.get('count') > 0)
.sort((a, b) => b.get('count') - a.get('count'));
if (numVisible >= 0) {
visibleReactions = visibleReactions.filter((_, i) => i < numVisible);
}
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
statusId={this.props.statusId}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
canReact={this.props.canReact}
/>
))}
</div>
)}
</TransitionMotion>
);
}
}
class Reaction extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
canReact: PropTypes.bool.isRequired,
style: PropTypes.object,
};
state = {
hovered: false,
};
handleClick = () => {
const { reaction, statusId, addReaction, removeReaction } = this.props;
if (reaction.get('me')) {
removeReaction(statusId, reaction.get('name'));
} else {
addReaction(statusId, reaction.get('name'));
}
}
handleMouseEnter = () => this.setState({ hovered: true })
handleMouseLeave = () => this.setState({ hovered: false })
render() {
const { reaction } = this.props;
return (
<button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
disabled={!this.props.canReact}
style={this.props.style}
>
<span className='reactions-bar__item__emoji'>
<Emoji
hovered={this.state.hovered}
emoji={reaction.get('name')}
url={reaction.get('url')}
staticUrl={reaction.get('static_url')}
/>
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.get('count')} />
</span>
</button>
);
}
}
class Emoji extends React.PureComponent {
static propTypes = {
emoji: PropTypes.string.isRequired,
hovered: PropTypes.bool.isRequired,
url: PropTypes.string,
staticUrl: PropTypes.string,
};
render() {
const { emoji, hovered, url, staticUrl } = this.props;
if (unicodeMapping[emoji]) {
const { filename, shortCode } = unicodeMapping[this.props.emoji];
const title = shortCode ? `:${shortCode}:` : '';
return (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else {
const filename = (autoPlayGif || hovered) ? url : staticUrl;
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
}
}
}

View file

@ -30,6 +30,8 @@ import {
unbookmark,
pin,
unpin,
addReaction,
removeReaction,
} from '../actions/interactions';
import { openModal } from '../actions/modal';
import { initMuteModal } from '../actions/mutes';
@ -48,7 +50,7 @@ import {
} from '../actions/statuses';
import Status from '../components/status';
import { boostModal, deleteModal } from '../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { makeGetStatus, makeGetPictureInPicture, makeCustomEmojiMap } from '../selectors';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -70,6 +72,7 @@ const makeMapStateToProps = () => {
status: getStatus(state, props),
nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null,
pictureInPicture: getPictureInPicture(state, props),
emojiMap: makeCustomEmojiMap(state),
});
return mapStateToProps;
@ -135,6 +138,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onReactionAdd (statusId, name, url) {
dispatch(addReaction(statusId, name, url));
},
onReactionRemove (statusId, name) {
dispatch(removeReaction(statusId, name));
},
onEmbed (status) {
dispatch(openModal({
modalType: 'EMBED',

View file

@ -322,6 +322,7 @@ class EmojiPickerDropdown extends PureComponent {
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
disabled: PropTypes.bool,
};
state = {
@ -355,7 +356,7 @@ class EmojiPickerDropdown extends PureComponent {
};
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {

View file

@ -119,6 +119,17 @@ export default class ColumnSettings extends PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span>
<div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>

View file

@ -8,6 +8,7 @@ import { Icon } from 'mastodon/components/icon';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
@ -75,6 +76,13 @@ class FilterBar extends PureComponent {
>
<Icon id='star' fixedWidth />
</button>
<button
className={selectedFilter === 'reaction' ? 'active' : ''}
onClick={this.onClick('reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='plus' fixedWidth />
</button>
<button
className={selectedFilter === 'reblog' ? 'active' : ''}
onClick={this.onClick('reblog')}

View file

@ -21,6 +21,7 @@ import Report from './report';
const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
reaction: { id: 'notification.reaction', defaultMessage: '{name} reacted to your status' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
@ -218,6 +219,38 @@ class Notification extends ImmutablePureComponent {
);
}
renderReaction (notification, link) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='plus' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.reaction' defaultMessage='{name} reacted to your status' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</HotKeys>
);
}
renderReblog (notification, link) {
const { intl, unread } = this.props;
@ -434,6 +467,8 @@ class Notification extends ImmutablePureComponent {
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reaction':
return this.renderReaction(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
case 'status':

View file

@ -12,7 +12,8 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
import { IconButton } from '../../../components/icon_button';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { me } from '../../../initial_state';
import { me, maxReactions } from '../../../initial_state';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -26,6 +27,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
@ -65,6 +67,7 @@ class ActionBar extends PureComponent {
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onReactionAdd: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@ -95,6 +98,10 @@ class ActionBar extends PureComponent {
this.props.onFavourite(this.props.status);
};
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''));
}
handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e);
};
@ -182,6 +189,8 @@ class ActionBar extends PureComponent {
navigator.clipboard.writeText(url);
};
handleNoOp = () => {} // hack for reaction add button
render () {
const { status, relationship, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -264,6 +273,21 @@ class ActionBar extends PureComponent {
}
}
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reactButton = (
<IconButton
className='plus-icon'
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
title={intl.formatMessage(messages.react)}
disabled={!canReact}
icon='plus'
/>
);
const shareButton = ('share' in navigator) && publicStatus && (
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
);
let replyIcon;
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
@ -289,6 +313,13 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'>
{
canReact
? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
: reactButton
}
</div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'>

View file

@ -12,6 +12,7 @@ import { AnimatedNumber } from 'mastodon/components/animated_number';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import StatusReactions from 'mastodon/components/status_reactions';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
@ -34,6 +35,7 @@ class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -52,6 +54,8 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onReactionAdd: PropTypes.func.isRequired,
onReactionRemove: PropTypes.func.isRequired,
};
state = {
@ -292,6 +296,14 @@ class DetailedStatus extends ImmutablePureComponent {
{media}
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
canReact={this.context.identity.signedIn}
/>
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />

View file

@ -42,6 +42,8 @@ import {
unreblog,
pin,
unpin,
addReaction,
removeReaction,
} from '../../actions/interactions';
import { openModal } from '../../actions/modal';
import { initMuteModal } from '../../actions/mutes';
@ -263,6 +265,19 @@ class Status extends ImmutablePureComponent {
}
};
handleReactionAdd = (statusId, name, url) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
dispatch(addReaction(statusId, name, url));
}
};
handleReactionRemove = (statusId, name) => {
this.props.dispatch(removeReaction(statusId, name));
};
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
@ -673,12 +688,15 @@ class Status extends ImmutablePureComponent {
status={status}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
onReactionAdd={this.handleReactionAdd}
onReactionRemove={this.handleReactionRemove}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
pictureInPicture={pictureInPicture}
emojiMap={this.props.emojiMap}
/>
<ActionBar
@ -686,6 +704,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReactionAdd={this.handleReactionAdd}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}

View file

@ -88,7 +88,7 @@ class LinkFooter extends PureComponent {
</p>
<p>
<strong>Mastodon</strong>:
<strong>Mastodon, queer.af edition</strong>:
{' '}
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
{DividingCircle}

View file

@ -61,6 +61,7 @@
* @property {boolean} limited_federation_mode
* @property {string} locale
* @property {string | null} mascot
* @property {number} max_reactions
* @property {string=} me
* @property {string=} moved_to_account_id
* @property {string=} owner
@ -80,6 +81,7 @@
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
* @property {string} version
* @property {number} visible_reactions
*/
/**
@ -88,6 +90,7 @@
* @property {InitialStateLanguage[]} languages
* @property {InitialStateMeta} meta
* @property {number} max_toot_chars
* @property {number} max_reactions
*/
const element = document.getElementById('initial-state');
@ -114,6 +117,7 @@ export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const maxReactions = (initialState && initialState.max_reactions) || 1;
export const me = getMeta('me');
export const movedToAccountId = getMeta('moved_to_account_id');
export const owner = getMeta('owner');
@ -132,6 +136,7 @@ export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const visibleReactions = getMeta('visible_reactions');
export const languages = initialState?.languages;
// @ts-expect-error
export const statusPageUrl = getMeta('status_page_url');

View file

@ -395,6 +395,7 @@
"notification.admin.report": "{name} meldete {target}",
"notification.admin.sign_up": "{name} registrierte sich",
"notification.favourite": "{name} hat deinen Beitrag favorisiert",
"notification.reaction": "{name} hat auf deinen Beitrag reagiert",
"notification.follow": "{name} folgt dir jetzt",
"notification.follow_request": "{name} möchte dir folgen",
"notification.mention": "{name} erwähnte dich",
@ -409,6 +410,7 @@
"notifications.column_settings.admin.sign_up": "Neue Registrierungen:",
"notifications.column_settings.alert": "Desktop-Benachrichtigungen",
"notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.reaction": "Reaktionen:",
"notifications.column_settings.filter_bar.advanced": "Erweiterte Filterleiste aktivieren",
"notifications.column_settings.filter_bar.category": "Filterleiste:",
"notifications.column_settings.filter_bar.show_bar": "Filterleiste anzeigen",
@ -592,6 +594,7 @@
"status.edited_x_times": "{count, plural, one {{count} mal} other {{count} mal}} bearbeitet",
"status.embed": "Beitrag per iFrame einbetten",
"status.favourite": "Favorisieren",
"status.react": "Reagieren",
"status.filter": "Beitrag filtern",
"status.filtered": "Gefiltert",
"status.hide": "Beitrag ausblenden",
@ -651,6 +654,7 @@
"timeline_hint.resources.statuses": "Ältere Beiträge",
"trends.counter_by_accounts": "{count, plural, one {{counter} Profil} other {{counter} Profile}} {days, plural, one {seit gestern} other {in {days} Tagen}}",
"trends.trending_now": "Aktuelle Trends",
"tooltips.reactions": "Reaktionen",
"ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
"units.short.billion": "{count} Mrd.",
"units.short.million": "{count} Mio.",

View file

@ -147,8 +147,8 @@
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
"compose_form.publish": "Publish",
"compose_form.publish_form": "New post",
"compose_form.publish": "Honk",
"compose_form.publish_form": "Honk",
"compose_form.publish_loud": "{publish}!",
"compose_form.save_changes": "Save changes",
"compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
@ -410,6 +410,7 @@
"notification.admin.report": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.favourite": "{name} favourited your post",
"notification.reaction": "{name} reacted to your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",
@ -424,6 +425,7 @@
"notifications.column_settings.admin.sign_up": "New sign-ups:",
"notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.favourite": "Favourites:",
"notifications.column_settings.reaction": "Reactions:",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
@ -610,6 +612,7 @@
"status.edited_x_times": "Edited {count, plural, one {{count} time} other {{count} times}}",
"status.embed": "Embed",
"status.favourite": "Favourite",
"status.react": "React",
"status.filter": "Filter this post",
"status.filtered": "Filtered",
"status.hide": "Hide post",
@ -638,7 +641,7 @@
"status.sensitive_warning": "Sensitive content",
"status.share": "Share",
"status.show_filter_reason": "Show anyway",
"status.show_less": "Show less",
"status.show_less": "Wait no",
"status.show_less_all": "Show less for all",
"status.show_more": "Show more",
"status.show_more_all": "Show more for all",
@ -667,6 +670,7 @@
"timeline_hint.resources.statuses": "Older posts",
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
"trends.trending_now": "Trending now",
"tooltips.reactions": "Reactions",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"units.short.billion": "{count}B",
"units.short.million": "{count}M",

View file

@ -100,7 +100,7 @@
"closed_registrations_modal.preamble": "Mastodon est décentralisé : peu importe où vous créez votre compte, vous serez en mesure de suivre et d'interagir avec quiconque sur ce serveur. Vous pouvez même l'héberger !",
"closed_registrations_modal.title": "Inscription sur Mastodon",
"column.about": "À propos",
"column.blocks": "Comptes bloqués",
"column.blocks": "Utilisateurs bloqués",
"column.bookmarks": "Signets",
"column.community": "Fil public local",
"column.direct": "Mention privée",
@ -140,7 +140,7 @@
"compose_form.poll.switch_to_multiple": "Changer le sondage pour autoriser plusieurs choix",
"compose_form.poll.switch_to_single": "Changer le sondage pour autoriser qu'un seul choix",
"compose_form.publish": "Publier",
"compose_form.publish_form": "Publier",
"compose_form.publish_form": "Publish",
"compose_form.publish_loud": "{publish}!",
"compose_form.save_changes": "Enregistrer les modifications",
"compose_form.sensitive.hide": "Marquer le média comme sensible",
@ -566,8 +566,8 @@
"search_results.statuses_fts_disabled": "La recherche de messages par leur contenu n'est pas activée sur ce serveur Mastodon.",
"search_results.title": "Rechercher {q}",
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
"server_banner.about_active_users": "Personnes utilisant ce serveur au cours des 30 derniers jours (Comptes actifs mensuellement)",
"server_banner.active_users": "comptes actifs",
"server_banner.about_active_users": "Personnes utilisant ce serveur au cours des 30 derniers jours (Utilisateur·rice·s Actifs·ives Mensuellement)",
"server_banner.active_users": "Utilisateurs actifs",
"server_banner.administered_by": "Administré par :",
"server_banner.introduction": "{domain} fait partie du réseau social décentralisé propulsé par {mastodon}.",
"server_banner.learn_more": "En savoir plus",

View file

@ -34,6 +34,7 @@ const initialState = ImmutableMap({
follow: false,
follow_request: false,
favourite: false,
reaction: false,
reblog: false,
mention: false,
poll: false,
@ -57,6 +58,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,
@ -70,6 +72,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,

View file

@ -10,6 +10,11 @@ import {
UNFAVOURITE_SUCCESS,
BOOKMARK_REQUEST,
BOOKMARK_FAIL,
REACTION_UPDATE,
REACTION_ADD_FAIL,
REACTION_REMOVE_FAIL,
REACTION_ADD_REQUEST,
REACTION_REMOVE_REQUEST,
} from '../actions/interactions';
import {
STATUS_MUTE_SUCCESS,
@ -37,6 +42,43 @@ const deleteStatus = (state, id, references) => {
return state.delete(id);
};
const updateReaction = (state, id, name, updater) => state.update(
id,
status => status.update(
'reactions',
reactions => {
const index = reactions.findIndex(reaction => reaction.get('name') === name);
if (index > -1) {
return reactions.update(index, reaction => updater(reaction));
} else {
return reactions.push(updater(fromJS({ name, count: 0 })));
}
},
),
);
const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count));
// The url parameter is only used when adding a new custom emoji reaction
// (one that wasn't in the reactions list before) because we don't have its
// URL yet. In all other cases, it's undefined.
const addReaction = (state, id, name, url) => updateReaction(
state,
id,
name,
x => x.set('me', true)
.update('count', n => n + 1)
.update('url', old => old ? old : url)
.update('static_url', old => old ? old : url),
);
const removeReaction = (state, id, name) => updateReaction(
state,
id,
name,
x => x.set('me', false).update('count', n => n - 1),
);
const statusTranslateSuccess = (state, id, translation) => {
return state.withMutations(map => {
map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id))));
@ -84,6 +126,14 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false);
case REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case REACTION_ADD_REQUEST:
case REACTION_REMOVE_FAIL:
return addReaction(state, action.id, action.name, action.url);
case REACTION_REMOVE_REQUEST:
case REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name);
case STATUS_MUTE_SUCCESS:
return state.setIn([action.id, 'muted'], true);
case STATUS_UNMUTE_SUCCESS:

View file

@ -138,6 +138,14 @@ export const getAccountHidden = createSelector([
return hidden && !(isSelf || followingOrRequested);
});
export const makeCustomEmojiMap = createSelector(
[state => state.get('custom_emojis')],
items => items.reduce(
(map, emoji) => map.set(emoji.get('shortcode'), emoji),
ImmutableMap(),
),
);
export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList());

View file

@ -0,0 +1 @@
Adapted from https://github.com/im-in-space/mastodon/tree/im-in.space/app/javascript/styles/modern

View file

@ -0,0 +1,253 @@
// Remove the left padding
#mastodon .status {
--status-left-padding: 15px !important;
}
/* Fixes */
// Remove thread line for now, breaking alignement
// and the available space
.status__line {
display: none !important;
}
.status--in-thread .status__content,
.status--in-thread .status__action-bar {
margin-inline-start: 0 !important;
padding-inline: 0 !important;
width: 100%;
}
// Fix for the new bio fields
.account__header__bio .account__header__fields {
margin-left: 15px !important;
margin-right: 15px !important;
}
.about__section__body,
.account__header__bio {
.account__header__fields {
max-width: calc(100% - 30px) !important;
margin-left: 15px !important;
margin-right: 15px !important;
dl {
display: block;
}
dd,
dt {
font-size: 13px;
line-height: 18px;
padding: 0;
text-align: initial;
}
dt,
dd.verified {
background: transparent !important;
border: none !important;
}
}
}
#mastodon {
// User profiles were not clickable when opening a status
.detailed-status.detailed-status__wrapper,
.detailed-status__wrapper.detailed-status__wrapper,
.picture-in-picture.detailed-status__wrapper {
margin-block: initial !important;
}
// Unread indicator not visible
.notification.unread::before,
.status.unread::before {
inset: 0 !important;
}
// Leave the roles under the profile picture
.account-timeline__header .account__header__bar .avatar .account-role {
margin-left: 0;
position: static;
}
// This fixes collapsed toots (we still override some parts bellow) but also
// clicking on username when you open their profile because ".detailled-satatus__wrapper"
// is at an another place and not with .focusable as expected upstream
.detailed-status__wrapper .status__content {
&::before,
&::after {
height: auto !important;
}
}
.detailed-status__wrapper .status__content__text,
.status:not(.collapsed) .status__content__text {
-webkit-mask: none !important;
mask: none !important;
}
.status {
// Modern sets the background to none,
// so put one back for direct statuses
&.status-direct {
background: lighten($ui-base-color, 8%) !important;
border-bottom-color: lighten($ui-base-color, 12%) !important;
}
// Fix the collapsed gradiant so it stays inside the "card"
&.collapsed {
.status__content {
overflow: hidden !important;
&::after {
bottom: 0 !important;
height: auto !important;
// left: 85px !important;
// right: 85px !important;
top: 0 !important;
}
}
}
}
// Make the @ address have a minimum width (so it wraps better)
// And flex: 1 to push the role tag to the right
.account__header__tabs__name h1 small {
flex: 1;
min-width: 150px;
}
// Don't make the profile header sticky, content goes thru it
.account-timeline__header .account__header__image,
.account-timeline__header
.account__header__image
.account__header__info
> span {
position: relative !important;
}
// Fix profile notes' textarea not usable at all
.account__header__fields textarea,
.account__header__account-note textarea {
width: -webkit-fill-available !important;
width: 100% !important;
}
// Fix icons/notifications being misaligned
.notification__message, // Here we need to get for _all_ notifications, or the bottom half of the notification message is cut in half
.status__prepend {
margin-bottom: 0 !important;
}
.notification .notification__message, // This rule only affect pure notifications (like "followed you/sign up")
.status__prepend {
// Move to the right
margin-left: 30px !important;
}
.status__info .notification__message {
// Here it's only the notifications with a post attached to them
// Reduce padding
padding-top: 0 !important;
}
.status__prepend {
// Reduce padding
padding-top: 8px !important;
}
// Remove useless background color change on focus/active behind the spoiler button
.status__content__spoiler-link {
&::after,
&::before {
background: transparent !important;
}
}
// Since we have more elements on the action bar (including the date/time)
// I'm reducing the padding a bit
.detailed-status__action-bar .icon-button,
.status__action-bar .icon-button {
padding: 0.2em !important;
min-width: 0 !important;
}
.status__action-bar-button {
margin-right: 8px;
}
// Stop showing the text next to action buttons
.icon-button[aria-label]::after {
content: none !important;
}
// Glitch still uses -- here while vanilla is __
// so I'm just kinda copy-pasting the styles here
.drawer--header {
background: lighten($ui-base-color, 8%);
border-radius: var(--radius-round);
margin-inline: 5px;
overflow: hidden;
border: 0 !important;
& > * {
border-bottom: none;
}
}
// Make the background on the compose column transparent
// which is better on mobile IMO
.drawer__inner:not(.darker),
.drawer__inner__mastodon {
background-color: transparent !important;
}
// The cancel button when replying was inaccessible
.compose-form .reply-indicator__header {
margin-bottom: -5px;
.account {
border-radius: 0 !important; // Fix weird radius
display: inline-block;
width: calc(100% - 1.28571em - 10px);
}
}
.pillbar-button {
// Fix unreadable options in the column settings when not selected
&:not(.active) {
color: $darker-text-color;
}
// And remove ugly box-shadow
&:not([disabled]).active {
box-shadow: none;
}
}
}
@media (width < 895px) {
#mastodon .status__action-bar {
// Reverting the margin on elements that were negative due to the
// big padding the theme used to have
margin-left: 0 !important;
}
}
// Some elements went above floating menus or PiP
// (like the spoiler media button or profile pictures)
.privacy-dropdown__dropdown,
.language-dropdown__dropdown,
.emoji-picker-dropdown__menu {
z-index: 500;
}
.picture-in-picture {
z-index: 501;
}
// Fix alignement in the sharing/following popups
.modal-layout.modal-layout .container-alt {
height: auto !important;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,60 @@
// Commonly used web colors
$black: #000000; // Black
$white: #ffffff; // White
$success-green: #79bd9a !default; // Padua
$error-red: #df405a !default; // Cerise
$warning-red: #ff5050 !default; // Sunset Orange
$gold-star: #ca8f04 !default; // Dark Goldenrod
$red-bookmark: $warning-red;
// Values from the classic Mastodon UI
$classic-base-color: #282c37; // Midnight Express
$classic-primary-color: #9baec8; // Echo Blue
$classic-secondary-color: #d9e1e8; // Pattens Blue
$classic-highlight-color: #e7b01c; // Summer Sky
// Variables for defaults in UI
$base-shadow-color: $black !default;
$base-overlay-background: $black !default;
$base-border-color: $white !default;
$simple-background-color: $white !default;
$valid-value-color: $success-green !default;
$error-value-color: $error-red !default;
// Tell UI to use selected colors
$ui-base-color: $classic-base-color !default; // Darkest
$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
$ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default;
// Variables for texts
$primary-text-color: $white !default;
$darker-text-color: $ui-primary-color !default;
$dark-text-color: $ui-base-lighter-color !default;
$secondary-text-color: $ui-secondary-color !default;
$highlight-text-color: $ui-highlight-color !default;
$action-button-color: $ui-base-lighter-color !default;
// For texts on inverted backgrounds
$inverted-text-color: $ui-base-color !default;
$lighter-text-color: $ui-base-lighter-color !default;
$light-text-color: $ui-primary-color !default;
// Language codes that uses CJK fonts
$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
// Variables for components
$media-modal-media-max-width: 100%;
// put margins on top and bottom of image to avoid the screen covered by image.
$media-modal-media-max-height: 80%;
$no-gap-breakpoint: 415px;
$font-sans-serif: 'mastodon-font-sans-serif' !default;
$font-display: 'mastodon-font-display' !default;
$font-monospace: 'mastodon-font-monospace' !default;
@import 'flavours/glitch/styles/index';
@import 'bits/style';
@import 'bits/glitch';

View file

@ -0,0 +1,4 @@
en:
skins:
glitch:
modern-dark: Modern (dark) by freeplay

View file

@ -0,0 +1,3 @@
Depends upon presence of the modern-dark theme
Adapted from https://github.com/im-in-space/mastodon/tree/im-in.space/app/javascript/styles/modern

View file

@ -0,0 +1,46 @@
// Dependent colors
$black: #000000;
$white: #ffffff;
$classic-base-color: #282c37;
$classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8;
$classic-highlight-color: #e7b01c;
// Differences
$success-green: lighten(#3c754d, 8%);
$base-overlay-background: $white !default;
$valid-value-color: $success-green !default;
$ui-base-color: $classic-secondary-color !default;
$ui-base-lighter-color: #b0c0cf;
$ui-primary-color: #9bcbed;
$ui-secondary-color: $classic-base-color !default;
$ui-highlight-color: #e7b01c;
$primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default;
$dark-text-color: #444b5d;
$action-button-color: #606984;
$inverted-text-color: $black !default;
$lighter-text-color: $classic-base-color !default;
$light-text-color: #444b5d;
//Newly added colors
$account-background-color: $white !default;
//Invert darkened and lightened colors
@function darken($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) + $amount);
}
@function lighten($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) - $amount);
}
@import 'flavours/glitch/styles/index';
@import '../modern-dark/bits/style';
@import '../modern-dark/bits/glitch';

View file

@ -0,0 +1,4 @@
en:
skins:
glitch:
modern-light: Modern (light) by freeplay

View file

@ -0,0 +1,71 @@
// Commonly used web colors
$black: #000000; // Black
$white: #ffffff; // White
$success-green: #79bd9a !default; // Padua
$error-red: #df405a !default; // Cerise
$warning-red: #ff5050 !default; // Sunset Orange
$gold-star: #ca8f04 !default; // Dark Goldenrod
$red-bookmark: $warning-red;
// Values from the classic Mastodon UI
$classic-base-color: #291533;
$classic-primary-color: #9902de;
$classic-secondary-color: #47de02;
$classic-highlight-color: #3bbf01;
// Variables for defaults in UI
$base-shadow-color: $black !default;
$base-overlay-background: $black !default;
$base-border-color: $white !default;
$simple-background-color: $white !default;
$valid-value-color: $success-green !default;
$error-value-color: $error-red !default;
// Tell UI to use selected colors
$ui-base-color: $classic-base-color !default; // Darkest
$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
$ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default;
// Variables for texts
$primary-text-color: $white !default;
$darker-text-color: $ui-primary-color !default;
$dark-text-color: $ui-base-lighter-color !default;
$secondary-text-color: $ui-secondary-color !default;
$highlight-text-color: $ui-highlight-color !default;
$action-button-color: $ui-base-lighter-color !default;
// For texts on inverted backgrounds
$inverted-text-color: $ui-base-color !default;
$lighter-text-color: $ui-base-lighter-color !default;
$light-text-color: $ui-primary-color !default;
// Language codes that uses CJK fonts
$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
// Variables for components
$media-modal-media-max-width: 100%;
// put margins on top and bottom of image to avoid the screen covered by image.
$media-modal-media-max-height: 80%;
$no-gap-breakpoint: 415px;
$font-sans-serif: 'TransportNew' !default;
$font-display: 'TransportNew' !default;
$font-monospace: 'mastodon-font-monospace' !default;
@import 'flavours/glitch/styles/index';
@import '../modern-dark/bits/style';
@import '../modern-dark/bits/glitch';
.status__content,
.account__header__content {
font-weight: 200;
}
.prose {
color: #fff;
font-weight: 200;
}

View file

@ -0,0 +1,4 @@
en:
skins:
glitch:
queer-af: queer.af, based upon modern-dark

View file

@ -0,0 +1,517 @@
// im-in.space theme
// taken from https://github.com/im-in-space/mastodon/tree/im-in.space/app/javascript/styles
// Might be missing
$classic-base-color: #282c37;
// Our color
$classic-highlight-color: #ff9800;
$ui-highlight-color: $classic-highlight-color;
$highlight-text-color: $ui-highlight-color;
@import 'flavours/glitch/styles/index';
// Some changes to the about pages
.about-body .wrapper {
background-color: rgba(0, 0, 0, 0.7);
margin: 0 auto;
max-width: 800px;
padding: 50px;
ol {
margin-bottom: 26px;
}
}
// Make UI fluid for large width (is useless for glitch)
// 1338px, we failed by ONE pixel
@media screen and (min-width: 1338px) {
body.layout-multiple-columns:not(.flavour-glitch) .column,
body.layout-multiple-columns:not(.flavour-glitch) .column__wrapper {
flex: 1 1 0;
}
}
// Background for DMs
.status.status-direct {
background: #313543 !important;
}
// Background for the gradient effect on collapsed toots
.status.collapsed .status__content:after {
background: linear-gradient(rgba(34, 34, 51, 0), #223);
}
// Make spoiler link button readable
.status__content .status__content__spoiler-link {
color: #37aac0;
}
// Revert margin to make the app scrollable horizontaly
.columns-area {
margin: 0;
}
// Removing some background
.drawer {
.drawer--header,
.contents {
background: transparent !important;
// Remove that outline when clicked
.mastodon {
outline: 0;
&::-moz-focus-inner {
border: none;
}
}
}
}
.columns-area__panels__pane--navigational .navigation-panel,
.getting-started {
background: transparent !important;
}
// More random fixes to the base theme
.tabs-bar__wrapper {
background-color: #223;
}
.compose-form__buttons {
button.icon-button.inverted {
color: #606984;
}
button.icon-button.inverted.active {
color: #ff9800;
}
}
/*
.endorsements-widget {
background: #223;
-webkit-box-shadow: 0 0 15px rgba(0, 0, 0, .2);
box-shadow: 0 0 15px rgba(0, 0, 0, .2);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
*/
.rich-formatting {
strong, b {
font-weight: bold;
}
}
.landing-page__information,
.landing-page__forms,
.landing-page #mastodon-timeline,
.box-widget,
.contact-widget,
.landing-page__information.contact-widget,
.landing-page__call-to-action,
.hero-widget__text,
.hero-widget__footer,
.table-of-contents,
.public-account-bio,
.public-account-header__bar::before,
.directory__card__bar,
.directory__card__extra,
.directory__tag > a,
.directory__tag > div {
background-color: #223 !important;
}
.button.logo-button {
background-color: #ff9800 !important;
color: #fff !important;
&:active,
&:focus,
&:hover {
background-color: #ffa51f !important;
svg {
path {
&:last-child {
fill: #ffa51f !important;
}
}
}
}
svg {
path {
&:last-child {
fill: #ff9800 !important;
}
}
}
}
.simple_form {
button,
.button,
.block-button {
background-color: #ff9800 !important;
color: #fff !important;
&:hover,
&:focus,
&:active {
background-color: #ffa51f !important;
}
}
}
.button.button-alternative {
background: #29293d !important;
color: #37aac0 !important;
&:active,
&:focus,
&:hover {
background: #29293d !important;
color: #37aac0 !important;
}
}
// (v1.16) https://userstyles.org/styles/140852/mastodon-flat-dark-and-colourful
.button {
background-color: #ff9800;
}
.button:hover,
.button:focus,
.button:active,
.button:disabled {
background-color: #ffa51f;
}
.ui {
background-color: #223;
}
.drawer .drawer__header,
.column-header,
.column-icon {
background-color: #223;
}
.column-icon {
color: #eee;
transition: all 200ms;
}
.column-icon:hover {
color: #fff;
}
.column-icon.collapsable {
background-color: #3aaacf;
color: #fff;
font-size: 20px !important;
padding-bottom: 13px !important;
}
.column-settings--outer,
.column-settings--section,
.setting-toggle {
background-color: #3aaacf;
color: #fff;
}
.setting-text,
.setting-text:hover,
.setting-text:focus {
color: #fff;
border-bottom: 2px solid #fff;
}
.react-toggle--checked .react-toggle-track,
.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
background-color: #223;
}
.react-toggle--checked .react-toggle-thumb {
border-color: #223;
}
.drawer .drawer__header .drawer__tab {
color: #3aaacf;
}
.drawer .drawer__header a:hover {
background-color: #223;
color: #61b4cf;
}
.search__input {
background-color: #223;
transition: all 200ms;
color: #fff;
}
.search__input:hover,
.search__input:focus {
background-color: #eee;
color: #000;
}
.drawer__inner {
background-color: #223;
}
.drawer__inner__mastodon {
background-color: #223 !important;
}
.navigation-bar {
color: #eee;
}
.navigation-bar strong {
font-size: 14px;
margin-top: 2px;
}
.search-results__header {
background-color: #223;
color: #eee;
text-transform: capitalize;
}
.drawer__inner.darker {
background-color: #223;
}
.account__display-name {
outline: none;
}
.display-name span {
color: #ddd;
}
.icon-button:hover,
.icon-button:focus,
.icon-button:active,
.icon-button.active {
color: #fff
}
.activity-stream .entry,
.status__prepend,
.status,
.column > .scrollable {
background-color: #223;
color: #fff;
// border-bottom: 0;
}
.notification__message {
color: #ccc;
}
.notification__filter-bar,
.notification__filter-bar button,
.account__section-headline button {
background: #223;
}
.status .status__relative-time {
color: #ccc;
}
.status__content a .fa,
.status__content a .fa:hover {
color: #3aaacf;
}
.status__content a,
.reply-indicator__content a,
.getting-started a,
.muted .status__content a,
.account__header .account__header__username {
color: #3aaacf;
}
.muted .status__display-name strong {
color: #ccc;
}
.status__prepend .status__display-name strong {
color: #fff;
outline: none;
}
.muted .status__content p {
color: #ccc;
}
.column-link,
.column {
background-color: #223;
}
.account__action-bar,
.account__action-bar__tab,
.detailed-status__action-bar {
border: 0;
background: #223;
}
.account__section-headline {
background: #223;
}
.account {
border-bottom: 0;
}
.column-back-button {
background-color: #223;
color: #ff9800;
}
.status__content .status__content__spoiler-link,
.reply-indicator__content .status__content__spoiler-link {
background: #29293d;
}
.autosuggest-textarea__suggestions {
background: #fff;
}
.autosuggest-textarea__suggestions__item:hover {
background: #3aaacf;
color: #fff;
}
.autosuggest-textarea__suggestions__item.selected {
background: #3aaacf;
color: #fff;
}
.reply-indicator {
background: #29293d;
}
.reply-indicator__content {
color: #fff
}
.reply-indicator .reply-indicator__display-name {
color: #fff
}
.search__icon .fa {
color: #ff9800;
}
.detailed-status {
background: #223;
}
/*
.account__header {
-webkit-box-flex: 0;
-ms-flex: 0 0 auto;
flex: 0 0 auto;
background: #223;
text-align: center;
background-size: cover;
background-position: center;
position: relative;
}
.account__header > div {
background: linear-gradient(to bottom, rgba(34, 34, 51, 0.10), #223);
}
*/
.icon-button.inverted {
color: #3aaacf
}
.icon-button.inverted.active {
color: #3aaacf
}
.icon-button.inverted:hover,
.icon-button.inverted:active,
.icon-button.inverted:focus {
color: #61b4cf
}
.text-icon-button.active {
color: #ff9800
}
.privacy-dropdown__option.active {
background: #3aaacf;
}
.privacy-dropdown__option:hover {
background: #3aaacf;
}
.privacy-dropdown__option.active:hover {
background: #3aaacf;
}
.column-header__button {
background: #223;
}
.column-header__button.active,
.column-header__button.active:hover {
background: #3aaacf;
}
.column-header__collapsible-inner {
background: #3aaacf;
color: #fff !important;
}
.column-settings__section {
color: #fff;
}
.setting-meta__label,
.setting-toggle__label {
color: #fff;
}
.column-subheading {
background: #223;
}
button.active .fa-retweet::after {
color: #ff9800;
content: "\f079";
}
.icon-button:hover,
.icon-button:focus,
.icon-button:active,
.icon-button.active {
color: #3aaacf;
}
.active .fa-star::before,
.fa.fa-fw.fa-star.star-icon::before,
.fa.fa-fw.fa-user-times {
color: #3aaacf;
}
.column-header__back-button {
background: #223;
color: #ff9800;
}
.column-header__back-button:hover {
color: #ffa51f;
}

View file

@ -0,0 +1,4 @@
en:
skins:
glitch:
space: im-in.space

View file

@ -1133,6 +1133,7 @@ body > [data-popper-placement] {
.status__content,
.status__action-bar,
.reactions-bar,
.media-gallery,
.video-player,
.audio-player,
@ -1185,6 +1186,10 @@ body > [data-popper-placement] {
}
}
}
.reactions-bar--empty {
margin-top: 0;
}
}
.status__relative-time {
@ -1320,6 +1325,16 @@ body > [data-popper-placement] {
align-items: center;
gap: 18px;
margin-top: 16px;
& > .emoji-picker-dropdown > .emoji-button {
padding: 0;
}
}
.status__action-bar-button {
.fa-plus {
padding-top: 1px;
}
}
.detailed-status__action-bar-dropdown {
@ -4263,6 +4278,10 @@ a.status-card.compact:hover {
text-align: center;
}
.detailed-status__button .emoji-button {
padding: 0;
}
.column-settings__outer {
background: lighten($ui-base-color, 8%);
padding: 15px;

View file

@ -39,6 +39,8 @@ class ActivityPub::Activity
ActivityPub::Activity::Follow
when 'Like'
ActivityPub::Activity::Like
when 'EmojiReact'
ActivityPub::Activity::EmojiReact
when 'Block'
ActivityPub::Activity::Block
when 'Update'
@ -176,4 +178,32 @@ class ActivityPub::Activity
Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_actor] && "via #{@options[:relayed_through_actor].uri}"}")
nil
end
# Ensure emoji declared in the activity's tags are
# present in the database and downloaded to the local cache.
# Required by EmojiReact and Like for emoji reactions.
def process_emoji_tags(name, tags)
tag = as_array(tags).find { |item| item['type'] == 'Emoji' }
return if tag.nil?
custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? || !name.eql?(custom_emoji_parser.shortcode)
emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
return emoji unless emoji.nil? ||
custom_emoji_parser.image_remote_url != emoji.image_remote_url ||
(custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
begin
emoji ||= CustomEmoji.new(domain: @account.domain,
shortcode: custom_emoji_parser.shortcode,
uri: custom_emoji_parser.uri)
emoji.image_remote_url = custom_emoji_parser.image_remote_url
emoji.save
rescue Seahorse::Client::NetworkingError => e
Rails.logger.warn "Error fetching emoji: #{e}"
return
end
emoji
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class ActivityPub::Activity::EmojiReact < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
name = @json['content']
return if original_status.nil? ||
!original_status.account.local? ||
delete_arrived_first?(@json['id'])
if /^:.*:$/.match?(name)
name.delete! ':'
custom_emoji = process_emoji_tags(name, @json['tag'])
return if custom_emoji.nil?
end
return if @account.reacted?(original_status, name, custom_emoji)
reaction = original_status.status_reactions.create!(account: @account, name: name, custom_emoji: custom_emoji)
LocalNotificationWorker.perform_async(original_status.account_id, reaction.id, 'StatusReaction', 'reaction')
rescue ActiveRecord::RecordInvalid
nil
end
end

View file

@ -3,12 +3,38 @@
class ActivityPub::Activity::Like < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id'])
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
return if maybe_process_misskey_reaction
return if @account.favourited?(original_status)
favourite = original_status.favourites.create!(account: @account)
LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite')
Trends.statuses.register(original_status)
end
# Misskey delivers reactions as likes with the emoji in _misskey_reaction
# see https://misskey-hub.net/ns.html#misskey-reaction for details
def maybe_process_misskey_reaction
original_status = status_from_uri(object_uri)
name = @json['_misskey_reaction']
return false if name.nil?
if /^:.*:$/.match?(name)
name.delete! ':'
custom_emoji = process_emoji_tags(name, @json['tag'])
return false if custom_emoji.nil? # invalid custom emoji, treat it as a regular like
end
return true if @account.reacted?(original_status, name, custom_emoji)
reaction = original_status.status_reactions.create!(account: @account, name: name, custom_emoji: custom_emoji)
LocalNotificationWorker.perform_async(original_status.account_id, reaction.id, 'StatusReaction', 'reaction')
true
# account tried to react with disabled custom emoji. Returning true to discard activity.
rescue ActiveRecord::RecordInvalid
true
end
end

View file

@ -11,6 +11,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
undo_follow
when 'Like'
undo_like
when 'EmojiReact'
undo_emoji_react
when 'Block'
undo_block
when nil
@ -108,6 +110,47 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
if @account.favourited?(status)
favourite = status.favourites.where(account: @account).first
favourite&.destroy
elsif @object['_misskey_reaction'].present?
undo_emoji_react
else
delete_later!(object_uri)
end
end
def undo_emoji_react
name = @object['content'] || @object['_misskey_reaction']
return if name.nil?
status = status_from_uri(target_uri)
return if status.nil? || !status.account.local?
if /^:.*:$/.match?(name)
name.delete! ':'
custom_emoji = process_emoji_tags(name, @object['tag'])
return if custom_emoji.nil?
end
if @account.reacted?(status, name, custom_emoji)
reaction = status.status_reactions.where(account: @account, name: name).first
reaction&.destroy
else
delete_later!(object_uri)
end
end
def undo_emoji_react
name = @object['content']
return if name.nil?
status = status_from_uri(target_uri)
return if status.nil? || !status.account.local?
if @account.reacted?(status, name.delete(':'))
reaction = status.status_reactions.where(account: @account, name: name).first
reaction&.destroy
else
delete_later!(object_uri)
end

View file

@ -13,6 +13,7 @@ module AccountAssociations
# Timelines
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :status_reactions, inverse_of: :account, dependent: :destroy
has_many :bookmarks, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy

View file

@ -243,6 +243,10 @@ module AccountInteractions
status.proper.favourites.where(account: self).exists?
end
def reacted?(status, name, custom_emoji = nil)
status.proper.status_reactions.where(account: self, name: name, custom_emoji: custom_emoji).exists?
end
def bookmarked?(status)
status.proper.bookmarks.where(account: self).exists?
end

View file

@ -127,6 +127,10 @@ module HasUserSettings
settings['hide_followers_count']
end
def setting_visible_reactions
integer_cast_setting('visible_reactions', 0)
end
def allows_report_emails?
settings['notification_emails.report']
end
@ -170,4 +174,14 @@ module HasUserSettings
def hide_all_media?
settings['web.display_media'] == 'hide_all'
end
def integer_cast_setting(key, min = nil, max = nil)
i = ActiveModel::Type::Integer.new.cast(settings[key])
# the cast above doesn't return a number if passed the string "e"
i = 0 unless i.is_a? Numeric
return min if !min.nil? && i < min
return max if !max.nil? && i > max
i
end
end

View file

@ -25,6 +25,7 @@ class Notification < ApplicationRecord
'Follow' => :follow,
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'StatusReaction' => :reaction,
'Poll' => :poll,
}.freeze
@ -35,6 +36,7 @@ class Notification < ApplicationRecord
follow
follow_request
favourite
reaction
poll
update
admin.sign_up
@ -46,6 +48,7 @@ class Notification < ApplicationRecord
reblog: [status: :reblog],
mention: [mention: :status],
favourite: [favourite: :status],
reaction: [status_reaction: :status],
poll: [poll: :status],
update: :status,
'admin.report': [report: :target_account],
@ -61,6 +64,7 @@ class Notification < ApplicationRecord
belongs_to :follow, inverse_of: :notification
belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification
belongs_to :status_reaction, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
end
@ -81,6 +85,8 @@ class Notification < ApplicationRecord
status&.reblog
when :favourite
favourite&.status
when :reaction
status_reaction&.status
when :mention
mention&.status
when :poll
@ -130,6 +136,8 @@ class Notification < ApplicationRecord
notification.status.reblog = cached_status
when :favourite
notification.favourite.status = cached_status
when :reaction
notification.reaction.status = cached_status
when :mention
notification.mention.status = cached_status
when :poll
@ -141,6 +149,8 @@ class Notification < ApplicationRecord
end
end
alias reaction status_reaction
after_initialize :set_from_account
before_validation :set_from_account
@ -150,7 +160,7 @@ class Notification < ApplicationRecord
return unless new_record?
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
when 'Status', 'Follow', 'Favourite', 'StatusReaction', 'FollowRequest', 'Poll', 'Report'
self.from_account_id = activity&.account_id
when 'Mention'
self.from_account_id = activity&.status&.account_id

View file

@ -71,6 +71,7 @@ class Status < ApplicationRecord
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :status_reactions, inverse_of: :status, dependent: :destroy
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@ -292,6 +293,21 @@ class Status < ApplicationRecord
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
end
def reactions(account = nil)
records = begin
scope = status_reactions.group(:status_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
if account.nil?
scope.select('name, custom_emoji_id, count(*) as count, false as me')
else
scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from status_reactions r where r.account_id = #{account.id} and r.status_id = status_reactions.status_id and r.name = status_reactions.name and (r.custom_emoji_id = status_reactions.custom_emoji_id or r.custom_emoji_id is null and status_reactions.custom_emoji_id is null)) as me")
end
end
ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
records
end
def ordered_media_attachments
if ordered_media_attachment_ids.nil?
media_attachments

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_reactions
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# status_id :bigint(8) not null
# name :string default(""), not null
# custom_emoji_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusReaction < ApplicationRecord
belongs_to :account
belongs_to :status, inverse_of: :status_reactions
belongs_to :custom_emoji, optional: true
has_one :notification, as: :activity, dependent: :destroy
validates :name, presence: true
validates_with StatusReactionValidator
before_validation :set_custom_emoji
private
# Sets custom_emoji to nil when disabled
def set_custom_emoji
self.custom_emoji = CustomEmoji.find_by(disabled: false, shortcode: name, domain: custom_emoji.domain) if name.present? && custom_emoji.present?
end
end

View file

@ -18,6 +18,7 @@ class UserSettings
setting :default_privacy, default: nil, in: %w(public unlisted private)
setting :default_content_type, default: 'text/plain'
setting :hide_followers_count, default: false
setting :visible_reactions, default: 6
namespace :web do
setting :crop_images, default: true

View file

@ -28,6 +28,10 @@ class StatusPolicy < ApplicationPolicy
show? && !blocking_author?
end
def react?
show? && !blocking_author?
end
def destroy?
owned?
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :content
attribute :virtual_object, key: :object
attribute :custom_emoji, key: :tag, unless: -> { object.custom_emoji.nil? }
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id].join
end
def type
'EmojiReact'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def virtual_object
ActivityPub::TagManager.instance.uri_for(object.status)
end
def content
if object.custom_emoji.nil?
object.name
else
":#{object.name}:"
end
end
alias reaction content
# Akkoma (and possibly others) expect `tag` to be an array, so we can't just
# use the has_one shorthand because we need to wrap it into an array manually
def custom_emoji
[ActivityPub::EmojiSerializer.new(object.custom_emoji).serializable_hash]
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::EmojiReactionSerializer
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id, '/undo'].join
end
def type
'Undo'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
end

View file

@ -6,7 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts,
:media_attachments, :settings,
:max_toot_chars, :poll_limits,
:languages
:languages, :max_reactions
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
has_one :role, serializer: REST::RoleSerializer
@ -15,6 +15,10 @@ class InitialStateSerializer < ActiveModel::Serializer
StatusLengthValidator::MAX_CHARS
end
def max_reactions
StatusReactionValidator::LIMIT
end
def poll_limits
{
max_options: PollValidator::MAX_OPTIONS,
@ -66,6 +70,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:default_content_type] = object.current_account.user.setting_default_content_type
store[:system_emoji_font] = object.current_account.user.setting_system_emoji_font
store[:crop_images] = object.current_account.user.setting_crop_images
store[:visible_reactions] = object.current_account.user.setting_visible_reactions
else
store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media

View file

@ -78,6 +78,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
translation: {
enabled: TranslationService.configured?,
},
reactions: {
max_reactions: StatusReactionValidator::LIMIT,
},
}
end

View file

@ -12,7 +12,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?

View file

@ -21,6 +21,14 @@ class REST::ReactionSerializer < ActiveModel::Serializer
object.custom_emoji.present?
end
def name
if extern?
[object.name, '@', object.custom_emoji.domain].join
else
object.name
end
end
def url
full_asset_url(object.custom_emoji.image.url)
end
@ -28,4 +36,10 @@ class REST::ReactionSerializer < ActiveModel::Serializer
def static_url
full_asset_url(object.custom_emoji.image.url(:static))
end
private
def extern?
custom_emoji? && object.custom_emoji.domain.present?
end
end

View file

@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_many :reactions, serializer: REST::ReactionSerializer
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
@ -148,6 +149,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.active_mentions.to_a.sort_by(&:id)
end
def reactions
object.reactions(current_user&.account)
end
class ApplicationSerializer < ActiveModel::Serializer
attributes :name, :website

View file

@ -97,6 +97,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
min_expiration: PollValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION,
},
reactions: {
max_reactions: StatusReactionValidator::LIMIT,
},
}
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ReactService < BaseService
include Authorization
include Payloadable
def call(account, status, emoji)
authorize_with account, status, :react?
name, domain = emoji.split('@')
return unless domain.nil? || status.local?
custom_emoji = CustomEmoji.find_by(shortcode: name, domain: domain)
reaction = StatusReaction.find_by(account: account, status: status, name: name, custom_emoji: custom_emoji)
return reaction unless reaction.nil?
reaction = StatusReaction.create!(account: account, status: status, name: name, custom_emoji: custom_emoji)
json = Oj.dump(serialize_payload(reaction, ActivityPub::EmojiReactionSerializer))
if status.account.local?
NotifyService.new.call(status.account, :reaction, reaction)
ActivityPub::RawDistributionWorker.perform_async(json, status.account.id)
else
ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url)
end
ActivityTracker.increment('activity:interactions')
reaction
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class UnreactService < BaseService
include Payloadable
def call(account, status, emoji)
name, domain = emoji.split('@')
custom_emoji = CustomEmoji.find_by(shortcode: name, domain: domain)
reaction = StatusReaction.find_by(account: account, status: status, name: name, custom_emoji: custom_emoji)
return if reaction.nil?
reaction.destroy!
json = Oj.dump(serialize_payload(reaction, ActivityPub::UndoEmojiReactionSerializer))
if status.account.local?
ActivityPub::RawDistributionWorker.perform_async(json, status.account.id)
else
ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url)
end
reaction
end
end

View file

@ -9,7 +9,7 @@ class PollValidator < ActiveModel::Validator
def validate(poll)
current_time = Time.now.utc
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 0
poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS
poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS }
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class StatusReactionValidator < ActiveModel::Validator
SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze
LIMIT = [1, (ENV['MAX_REACTIONS'] || 1).to_i].max
def validate(reaction)
return if reaction.name.blank?
reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name)
reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if reaction.account.local? && new_reaction?(reaction) && limit_reached?(reaction)
end
private
def unicode_emoji?(name)
SUPPORTED_EMOJIS.include?(name)
end
def new_reaction?(reaction)
!reaction.status.status_reactions.exists?(status: reaction.status, account: reaction.account, name: reaction.name, custom_emoji: reaction.custom_emoji)
end
def limit_reached?(reaction)
reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT
end
end

View file

@ -43,7 +43,8 @@
= render partial: 'layouts/theme', object: @core
= render partial: 'layouts/theme', object: @theme
= stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
- if Setting.custom_css.present?
= stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
%body{ class: body_classes }
= content_for?(:content) ? yield(:content) : yield

View file

@ -42,6 +42,9 @@
.fields-group
= ff.input :'web.crop_images', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_crop_images')
.fields-group.fields-row__column.fields-row__column-6
= ff.input :'visible_reactions', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_visible_reactions'), input_html: { type: 'number', min: '0', data: { default: '6' } }, hint: false
%h4= t 'appearance.discovery'
.fields-group

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class UnreactWorker
include Sidekiq::Worker
def perform(account_id, status_id, emoji)
UnreactService.new.call(Account.find(account_id), Status.find(status_id), emoji)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -40,3 +40,8 @@ de:
use_this: Benutze das
settings:
flavours: Varianten
notification_mailer:
reaction:
body: "%{name} hat auf deinen Beitrag reagiert:"
subject: "%{name} hat auf deinen Beitrag reagiert"
title: Neue Reaktion

View file

@ -38,5 +38,15 @@ en:
title: User verification
generic:
use_this: Use this
notification_mailer:
reaction:
body: "%{name} reacted to your post:"
subject: "%{name} reacted to your post"
title: New reaction
settings:
flavours: Flavours
notification_mailer:
reaction:
body: "%{name} reacted to your post:"
subject: "%{name} reacted to your post"
title: New reaction

View file

@ -40,3 +40,8 @@ fr:
use_this: Utiliser ceci
settings:
flavours: Thèmes
notification_mailer:
reaction:
body: "%{name} a réagi·e à votre message:"
subject: "%{name} a réagi·e à votre message"
title: Nouvelle réaction

View file

@ -21,6 +21,7 @@ de:
setting_hide_followers_count: Anzahl der Follower verbergen
setting_skin: Skin
setting_system_emoji_font: Systemschriftart für Emojis verwenden (nur für Glitch-Variante)
setting_visible_reactions: Anzahl der sichtbaren Emoji-Reaktionen
notification_emails:
trending_link: Neuer angesagter Link muss überprüft werden
trending_status: Neuer angesagter Post muss überprüft werden

View file

@ -20,6 +20,7 @@ en:
setting_hide_followers_count: Hide your followers count
setting_skin: Skin
setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only)
setting_visible_reactions: Number of visible emoji reactions
notification_emails:
trending_link: New trending link requires review
trending_status: New trending post requires review

View file

@ -21,7 +21,9 @@ fr:
setting_hide_followers_count: Cacher votre nombre d'abonné·e·s
setting_skin: Thème
setting_system_emoji_font: Utiliser la police par défaut du système pour les émojis (s'applique uniquement au mode Glitch)
setting_visible_reactions: Nombre de réactions emoji visibles
notification_emails:
trending_link: Un nouveau lien en tendances nécessite un examen
trending_status: Un nouveau post en tendances nécessite un examen
trending_tag: Un nouveau tag en tendances nécessite un examen
setting_visible_reactions: Nombre de réactions emoji visibles

View file

@ -1355,6 +1355,10 @@ de:
title: Neue Erwähnung
poll:
subject: Eine Umfrage von %{name} ist beendet
reaction:
body: "%{name} hat auf deinen Beitrag reagiert:"
subject: "%{name} hat auf deinen Beitrag reagiert"
title: Neue Reaktion
reblog:
body: 'Deinen Beitrag hat %{name} geteilt:'
subject: "%{name} teilte deinen Beitrag"

View file

@ -1433,6 +1433,10 @@ en:
title: New mention
poll:
subject: A poll by %{name} has ended
reaction:
body: "%{name} reacted to your post:"
subject: "%{name} reacted to your post"
title: New reaction
reblog:
body: 'Your post was boosted by %{name}:'
subject: "%{name} boosted your post"

View file

@ -1339,6 +1339,10 @@ fr:
title: Nouvelle mention
poll:
subject: Un sondage de %{name} est terminé
reaction:
body: "%{name} a réagi·e à votre message:"
subject: "%{name} a réagi·e à votre message"
title: Nouvelle réaction
reblog:
body: 'Votre message été partagé par %{name} :'
subject: "%{name} a partagé votre message"

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