Compare commits

...

137 commits

Author SHA1 Message Date
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
Claire 6a4be4e966
Merge pull request #2119 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
2023-02-26 15:06:03 +01:00
Claire b91756fd4d Move misc rules from components/index.scss to components/misc.scss 2023-02-25 23:47:21 +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
122 changed files with 11428 additions and 1875 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,11 @@
# Mastodon Glitch Edition #
# Mastodon, Queer.af+Glitch Edition #
> Now with automated deploys!
We're a downstream of [glitch-soc](https://github.com/glitch-soc/mastodon/). You probably want that
[![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
nix-prefetch-yarn
# Update yarn hash in default.nix
```

View file

@ -0,0 +1,25 @@
# 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_empty
end
def destroy
UnreactService.new.call(current_account, @status, params[:id])
render_empty
end
private
def set_status
@status = Status.find(params[:status_id])
end
end

View file

@ -57,6 +57,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_use_pending_items,
:setting_trends,
:setting_crop_images,
:setting_visible_reactions,
:setting_always_send_emails,
notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag trending_link trending_status appeal),
interactions: %i(must_be_follower must_be_following must_be_following_dm)

View file

@ -41,6 +41,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));
@ -391,4 +401,77 @@ export function unpinFail(status, error) {
status,
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

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

View file

@ -6,6 +6,7 @@ import StatusHeader from './status_header';
import StatusIcons from './status_icons';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import StatusReactions from './status_reactions';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
@ -16,7 +17,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications
import classNames from 'classnames';
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
import PollContainer from 'flavours/glitch/containers/poll_container';
import { displayMedia } from 'flavours/glitch/initial_state';
import { displayMedia, visibleReactions } from 'flavours/glitch/initial_state';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
// We use the component (and not the container) since we do not want
@ -61,6 +62,7 @@ class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -75,6 +77,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,
@ -729,6 +733,7 @@ class Status extends ImmutablePureComponent {
if (this.props.prepend && account) {
const notifKind = {
favourite: 'favourited',
reaction: 'reacted',
reblog: 'boosted',
reblogged_by: 'boosted',
status: 'posted',
@ -807,6 +812,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

@ -5,11 +5,13 @@ import IconButton from './icon_button';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from 'flavours/glitch/initial_state';
import { me, maxReactions } from 'flavours/glitch/initial_state';
import RelativeTimestamp from './relative_timestamp';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -28,6 +30,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}' },
@ -58,6 +61,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,
@ -115,6 +119,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;
@ -196,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'));
@ -300,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
@ -312,6 +332,11 @@ 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} />

View file

@ -56,6 +56,14 @@ export default class StatusPrepend extends React.PureComponent {
values={{ name : link }}
/>
);
case 'reaction':
return (
<FormattedMessage
id='notification.reaction'
defaultMessage='{name} reacted to your status'
values={{ name: link }}
/>
);
case 'reblog':
return (
<FormattedMessage
@ -110,6 +118,9 @@ export default class StatusPrepend extends React.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

@ -1,6 +1,5 @@
import { connect } from 'react-redux';
import Status from 'flavours/glitch/components/status';
import { List as ImmutableList } from 'immutable';
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import {
replyCompose,
@ -16,6 +15,8 @@ import {
unbookmark,
pin,
unpin,
addReaction,
removeReaction,
} from 'flavours/glitch/actions/interactions';
import {
muteStatus,
@ -163,6 +164,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('EMBED', {
url: status.get('url'),

View file

@ -318,6 +318,7 @@ class EmojiPickerDropdown extends React.PureComponent {
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
disabled: PropTypes.bool,
};
state = {
@ -351,7 +352,7 @@ class EmojiPickerDropdown extends React.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

@ -116,6 +116,17 @@ export default class ColumnSettings extends React.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

@ -6,6 +6,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' },
@ -74,6 +75,13 @@ class FilterBar extends React.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

@ -157,6 +157,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

@ -4,10 +4,11 @@ import IconButton from 'flavours/glitch/components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me } from 'flavours/glitch/initial_state';
import { me, maxReactions } from 'flavours/glitch/initial_state';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -21,6 +22,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}' },
@ -52,6 +54,7 @@ class ActionBar extends React.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,
@ -78,6 +81,10 @@ class ActionBar extends React.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);
};
@ -138,6 +145,8 @@ class ActionBar extends React.PureComponent {
navigator.clipboard.writeText(url);
};
handleNoOp = () => {} // hack for reaction add button
render () {
const { status, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -195,6 +204,17 @@ class ActionBar extends React.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>
);
@ -217,6 +237,13 @@ class ActionBar extends React.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>

View file

@ -20,12 +20,14 @@ import Icon from 'flavours/glitch/components/icon';
import AnimatedNumber from 'flavours/glitch/components/animated_number';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
import StatusReactions from 'flavours/glitch/components/status_reactions';
export default @injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -46,6 +48,8 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onReactionAdd: PropTypes.func.isRequired,
onReactionRemove: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -322,6 +326,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

@ -30,6 +30,8 @@ import {
unreblog,
pin,
unpin,
addReaction,
removeReaction,
} from 'flavours/glitch/actions/interactions';
import {
replyCompose,
@ -41,7 +43,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports';
import { initBoostModal } from 'flavours/glitch/actions/boosts';
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import { makeCustomEmojiMap, makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import ColumnHeader from '../../components/column_header';
@ -296,6 +298,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));
@ -681,6 +696,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}
@ -695,6 +712,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

@ -83,7 +83,7 @@ class LinkFooter extends React.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
* @property {boolean} translation_enabled
* @property {object} local_settings
*/
@ -122,6 +124,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');
@ -140,6 +143,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 translationEnabled = getMeta('translation_enabled');
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

@ -57,6 +57,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.",
@ -71,6 +72,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",
@ -124,6 +127,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",
@ -137,6 +141,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",
@ -179,6 +184,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

@ -57,6 +57,7 @@
"keyboard_shortcuts.bookmark": "to bookmark",
"keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
"keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
"tooltips.reactions": "Reactions",
"layout.auto": "Auto",
"layout.desktop": "Desktop",
"layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
@ -71,6 +72,8 @@
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
"navigation_bar.misc": "Misc",
"notification.markForDeletion": "Mark for deletion",
"notification.reaction": "{name} reacted to your post",
"notifications.column_settings.reaction": "Reactions:",
"notification_purge.btn_all": "Select\nall",
"notification_purge.btn_apply": "Clear\nselected",
"notification_purge.btn_invert": "Invert\nselection",
@ -124,6 +127,7 @@
"settings.deprecated_setting": "This setting is now controlled from Mastodon's {settings_page_link}",
"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.enter_amount_prompt": "Enter an amount",
"settings.enable_content_warnings_auto_unfold": "Automatically unfold content-warnings",
"settings.general": "General",
"settings.hicolor_privacy_icons": "High color privacy icons",
@ -137,6 +141,7 @@
"settings.layout_opts": "Layout options",
"settings.media": "Media",
"settings.media_fullwidth": "Full-width media previews",
"settings.num_visible_reactions": "Number of visible reactions",
"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_reveal_behind_cw": "Reveal sensitive media behind a CW by default",
@ -179,6 +184,7 @@
"settings.wide_view": "Wide view (Desktop mode only)",
"settings.wide_view_hint": "Stretches columns to better fill the available space.",
"status.collapse": "Collapse",
"status.react": "React",
"status.has_audio": "Features attached audio files",
"status.has_pictures": "Features attached pictures",
"status.has_preview_card": "Features an attached preview card",

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

@ -57,6 +57,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.",
@ -71,6 +72,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",
@ -123,6 +126,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",
@ -136,6 +140,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",
@ -186,6 +191,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

@ -37,6 +37,7 @@ const initialState = ImmutableMap({
follow: false,
follow_request: false,
favourite: false,
reaction: false,
reblog: false,
mention: false,
poll: false,
@ -60,6 +61,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,
@ -73,6 +75,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,

View file

@ -6,6 +6,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,
@ -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 initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@ -63,6 +105,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

@ -1,6 +1,6 @@
import escapeTextContentForBrowser from 'escape-html';
import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { toServerSideType } from 'flavours/glitch/utils/filters';
import { me } from 'flavours/glitch/initial_state';

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -463,6 +463,10 @@
.notification__message {
margin: -10px 0 10px;
}
.reactions-bar--empty {
display: none;
}
}
.notification-favourite {
@ -607,6 +611,10 @@
align-items: center;
display: flex;
margin-top: 8px;
& > .emoji-picker-dropdown > .emoji-button {
padding: 0;
}
}
.status__action-bar-button {
@ -615,6 +623,10 @@
&.icon-button--with-counter {
margin-right: 14px;
}
.fa-plus {
padding-top: 1px;
}
}
.status__action-bar-dropdown {
@ -681,6 +693,10 @@
display: flex;
flex-direction: row;
padding: 10px 0;
.fa-plus {
padding-top: 2px;
}
}
.detailed-status__link {

View file

@ -41,6 +41,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));
@ -412,3 +422,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

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

View file

@ -7,6 +7,7 @@ import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import StatusReactions from './status_reactions';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
@ -15,7 +16,7 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import { displayMedia } from '../initial_state';
import { displayMedia, visibleReactions } from '../initial_state';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
// We use the component (and not the container) since we do not want
@ -64,6 +65,7 @@ class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -76,6 +78,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,
@ -99,6 +103,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,
@ -538,6 +543,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

@ -6,9 +6,10 @@ import IconButton from './icon_button';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import { me, maxReactions } from '../initial_state';
import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -27,6 +28,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' },
@ -67,6 +69,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,
@ -128,6 +131,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;
@ -231,6 +238,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;
@ -357,11 +366,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} />
{shareButton}

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

@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import Status from '../components/status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { makeGetStatus, makeGetPictureInPicture, makeCustomEmojiMap } from '../selectors';
import {
replyCompose,
mentionCompose,
@ -16,6 +16,8 @@ import {
unbookmark,
pin,
unpin,
addReaction,
removeReaction,
} from '../actions/interactions';
import {
muteStatus,
@ -66,6 +68,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
pictureInPicture: getPictureInPicture(state, props),
emojiMap: makeCustomEmojiMap(state),
});
return mapStateToProps;
@ -129,6 +132,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('EMBED', {
url: status.get('url'),

View file

@ -316,6 +316,7 @@ class EmojiPickerDropdown extends React.PureComponent {
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
disabled: PropTypes.bool,
};
state = {
@ -349,7 +350,7 @@ class EmojiPickerDropdown extends React.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

@ -115,6 +115,17 @@ export default class ColumnSettings extends React.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

@ -6,6 +6,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' },
@ -74,6 +75,13 @@ class FilterBar extends React.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

@ -15,6 +15,7 @@ import classNames from 'classnames';
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' },
@ -213,6 +214,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;
@ -429,6 +462,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

@ -5,9 +5,10 @@ import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me } from '../../../initial_state';
import { me, maxReactions } from '../../../initial_state';
import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -21,6 +22,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}' },
@ -62,6 +64,7 @@ class ActionBar extends React.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,
@ -92,6 +95,10 @@ class ActionBar extends React.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);
};
@ -180,6 +187,8 @@ class ActionBar extends React.PureComponent {
navigator.clipboard.writeText(url);
};
handleNoOp = () => {} // hack for reaction add button
render () {
const { status, relationship, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -257,6 +266,17 @@ class ActionBar extends React.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>
);
@ -286,6 +306,13 @@ class ActionBar extends React.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>
{shareButton}

View file

@ -17,6 +17,7 @@ import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import StatusReactions from 'mastodon/components/status_reactions';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -30,6 +31,7 @@ class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -48,6 +50,8 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onReactionAdd: PropTypes.func.isRequired,
onReactionRemove: PropTypes.func.isRequired,
};
state = {
@ -275,6 +279,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

@ -30,6 +30,8 @@ import {
unreblog,
pin,
unpin,
addReaction,
removeReaction,
} from '../../actions/interactions';
import {
replyCompose,
@ -48,7 +50,7 @@ import { initMuteModal } from '../../actions/mutes';
import { initBlockModal } from '../../actions/blocks';
import { initBoostModal } from '../../actions/boosts';
import { initReport } from '../../actions/reports';
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
import { makeCustomEmojiMap, makeGetStatus, makeGetPictureInPicture } from '../../selectors';
import ScrollContainer from 'mastodon/containers/scroll_container';
import ColumnBackButton from '../../components/column_back_button';
import ColumnHeader from '../../components/column_header';
@ -153,6 +155,7 @@ const makeMapStateToProps = () => {
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
emojiMap: makeCustomEmojiMap(state),
};
};
@ -254,6 +257,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));
@ -638,12 +654,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
@ -651,6 +670,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

@ -83,7 +83,7 @@ class LinkFooter extends React.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
* @property {boolean} translation_enabled
*/
@ -114,6 +116,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 +135,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 translationEnabled = getMeta('translation_enabled');
export const languages = initialState?.languages;
export const statusPageUrl = getMeta('status_page_url');

View file

@ -397,6 +397,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",
@ -411,6 +412,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",
@ -561,6 +563,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",
@ -619,6 +622,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

@ -142,8 +142,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": "Publish",
"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}}",
@ -402,6 +402,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",
@ -416,6 +417,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",
@ -566,6 +568,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",
@ -594,7 +597,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",
@ -624,6 +627,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

@ -99,7 +99,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": "Messages directs",
@ -139,7 +139,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",
@ -536,8 +536,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

@ -33,6 +33,7 @@ const initialState = ImmutableMap({
follow: false,
follow_request: false,
favourite: false,
reaction: false,
reblog: false,
mention: false,
poll: false,
@ -56,6 +57,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,
@ -69,6 +71,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,

View file

@ -6,6 +6,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,
@ -35,6 +40,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 initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@ -61,6 +103,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

@ -135,3 +135,11 @@ export const getAccountHidden = createSelector([
], (hidden, followingOrRequested, isSelf) => {
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(),
),
);

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,189 @@
// Remove the left padding
#mastodon .status {
--status-left-padding: 15px !important;
}
/* Fixes */
// While we wait for glitch update
.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;
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;
}
}
}
// 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
#mastodon .detailed-status__wrapper .status__content::before,
#mastodon .detailed-status__wrapper .status__content::after {
height: auto !important;
}
#mastodon {
.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;
}
}
}
}
// Add some spacing around the role on the other side too
.account-timeline__header .account-role {
margin-left: 0.4em;
}
// 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;
}
// 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;
}
// The -10px here breaks a lot in Glitch, like collapsed statuses don't show
// the basic info, or it's just too close to the user name. It's fine at 0.
// Also fix alignement with a smaller padding-top.
.notification__message,
.status__prepend {
margin-bottom: 0px !important;
padding-top: 8px !important;
}
// For the calc(), it's because it slightly cuts off the icons on the left due
// to removing the big left padding.
.status__prepend {
margin-inline-start: calc(var(--status-left-padding) + 15px);
}
// 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: .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 (max-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,
.picture-in-picture {
z-index: 500;
}
// Fix alignement in the sharing/following popups
.modal-layout.modal-layout .container-alt {
height: auto !important;
}
// This was used in mastodon.coffee, I like it
.layout-multiple-columns .column {
flex-grow: 1;
max-width: 500px;
}

File diff suppressed because it is too large Load diff

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

@ -1145,6 +1145,10 @@ body > [data-popper-placement] {
}
}
}
.reactions-bar--empty {
margin-top: 0;
}
}
.status__relative-time {
@ -1268,6 +1272,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 {
@ -4087,6 +4101,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,33 @@ 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 all emojis 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(tags)
as_array(tags).each do |tag|
process_single_emoji tag if tag['type'] == 'Emoji'
end
end
def process_single_emoji(tag)
custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
return 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}"
end
end
end

View file

@ -0,0 +1,25 @@
# 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']) ||
@account.reacted?(original_status, name)
custom_emoji = nil
if name =~ /^:.*:$/
process_emoji_tags(@json['tag'])
name.delete! ':'
custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain)
return if custom_emoji.nil?
end
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')
end
end

View file

@ -3,12 +3,36 @@
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(original_status)
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)
name = @json['_misskey_reaction']
return false if name.nil?
custom_emoji = nil
if name =~ /^:.*:$/
process_emoji_tags(@json['tag'])
name.delete! ':'
custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain)
return false if custom_emoji.nil? # invalid custom emoji, treat it as a regular like
end
return true if @account.reacted?(original_status, name)
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
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
@ -113,6 +115,22 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
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
end
def undo_block
target_account = account_from_uri(target_uri)

View file

@ -43,6 +43,7 @@ class UserSettingsDecorator
user.settings['use_pending_items'] = use_pending_items_preference if change?('setting_use_pending_items')
user.settings['trends'] = trends_preference if change?('setting_trends')
user.settings['crop_images'] = crop_images_preference if change?('setting_crop_images')
user.settings['visible_reactions'] = visible_reactions_preference if change?('setting_visible_reactions')
user.settings['always_send_emails'] = always_send_emails_preference if change?('setting_always_send_emails')
end
@ -158,6 +159,10 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_crop_images'
end
def visible_reactions_preference
integer_cast_setting('setting_visible_reactions', 0)
end
def always_send_emails_preference
boolean_cast_setting 'setting_always_send_emails'
end
@ -166,6 +171,15 @@ class UserSettingsDecorator
ActiveModel::Type::Boolean.new.cast(settings[key])
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
def coerced_settings(key)
coerce_values settings.fetch(key, {})
end

View file

@ -237,6 +237,10 @@ module AccountInteractions
status.proper.favourites.where(account: self).exists?
end
def reacted?(status, name)
status.proper.status_reactions.where(account: self, name: name).exists?
end
def bookmarked?(status)
status.proper.bookmarks.where(account: self).exists?
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],
@ -62,6 +65,7 @@ class Notification < ApplicationRecord
belongs_to :favourite, foreign_key: 'activity_id', optional: true
belongs_to :poll, foreign_key: 'activity_id', optional: true
belongs_to :report, foreign_key: 'activity_id', optional: true
belongs_to :status_reaction, foreign_key: 'activity_id', optional: true
validates :type, inclusion: { in: TYPES }
@ -79,6 +83,8 @@ class Notification < ApplicationRecord
status&.reblog
when :favourite
favourite&.status
when :reaction
status_reaction&.status
when :mention
mention&.status
when :poll
@ -128,6 +134,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
@ -139,6 +147,8 @@ class Notification < ApplicationRecord
end
end
alias reaction status_reaction
after_initialize :set_from_account
before_validation :set_from_account
@ -148,7 +158,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

@ -72,6 +72,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
@ -264,6 +265,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) 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,31 @@
# 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
def set_custom_emoji
self.custom_emoji = CustomEmoji.find_by(shortcode: name, domain: account.domain) if name.blank?
end
end

View file

@ -135,7 +135,7 @@ class User < ApplicationRecord
delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
:reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_followers_count,
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
:advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
:advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :visible_reactions,
:disable_swiping, :always_send_emails, :default_content_type, :system_emoji_font,
to: :settings, prefix: :setting, allow_nil: false

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,
@ -67,6 +71,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
@ -146,6 +147,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,27 @@
# frozen_string_literal: true
class ReactService < BaseService
include Authorization
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 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,24 @@
# 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? && limit_reached?(reaction)
end
private
def unicode_emoji?(name)
SUPPORTED_EMOJIS.include?(name)
end
def limit_reached?(reaction)
reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT
end
end

View file

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

View file

@ -38,6 +38,9 @@
.fields-group
= f.input :setting_crop_images, as: :boolean, wrapper: :with_label
.fields-group.fields-row__column.fields-row__column-6
= f.input :setting_visible_reactions, wrapper: :with_label, input_html: { type: 'number', min: '0', data: { default: '6' } }, hint: false
%h4= t 'appearance.discovery'
.fields-group

View file

@ -138,7 +138,7 @@ Rails.application.configure do
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '0',
'Permissions-Policy' => 'interest-cohort=()',
'X-Clacks-Overhead' => 'GNU Natalie Nguyen',
'X-Clacks-Overhead' => 'GNU Anna Harren',
'Referrer-Policy' => 'same-origin',
}

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

@ -40,3 +40,8 @@ en:
use_this: Use this
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

@ -21,6 +21,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

@ -1344,6 +1344,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} hat deinen Beitrag geteilt"

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