From a1ec9f52694d000bed8c87eb723d66aa40074995 Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Fri, 12 Jan 2024 20:17:08 -0500 Subject: [PATCH 1/9] Fix large emojis in reactions modal --- js/src/common/components/ReactionComponent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/common/components/ReactionComponent.js b/js/src/common/components/ReactionComponent.js index 3e593dd..4080de5 100644 --- a/js/src/common/components/ReactionComponent.js +++ b/js/src/common/components/ReactionComponent.js @@ -19,9 +19,9 @@ export default class ReactionComponent extends Component { if (reaction.type() === 'emoji') { const { url } = emoji(reaction.identifier()); - return {display}; + return {display}; } else { - return ; + return ; } } } From ae3c82cb7d88a6328186ef1acc2ebc017cd28109 Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Fri, 12 Jan 2024 21:24:26 -0500 Subject: [PATCH 2/9] Add ability to delete post reactions individually from posts --- .editorconfig | 5 +- extend.php | 1 + js/src/@types/shims.d.ts | 24 ++++++++ js/src/admin/index.js | 8 +++ js/src/forum/components/ReactionsModal.tsx | 58 +++++++++++++++++-- js/src/forum/index.js | 1 + resources/less/forum.less | 8 +++ resources/locale/en.yml | 3 +- .../DeletePostReactionController.php | 36 ++++++++++++ src/PostAttributes.php | 1 + 10 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 js/src/@types/shims.d.ts create mode 100644 src/Api/Controller/DeletePostReactionController.php diff --git a/.editorconfig b/.editorconfig index cbb6ac5..e0acaae 100755 --- a/.editorconfig +++ b/.editorconfig @@ -7,5 +7,6 @@ insert_final_newline = true indent_style = space indent_size = 4 [*.md] -indent_size = 2 -trim_trailing_whitespace = false \ No newline at end of file +trim_trailing_whitespace = false +[*.{.md,js}] +indent_size = 2 \ No newline at end of file diff --git a/extend.php b/extend.php index 3b97c2a..dd3d33a 100644 --- a/extend.php +++ b/extend.php @@ -37,6 +37,7 @@ (new Extend\Routes('api')) ->get('/posts/{id}/reactions', 'post.reactions.index', Controller\ListPostReactionsController::class) + ->delete('/posts/{id}/reactions/{reactionId}', 'post.reactions.delete', Controller\DeletePostReactionController::class) ->get('/reactions', 'reactions.index', Controller\ListReactionsController::class) ->post('/reactions', 'reactions.create', Controller\CreateReactionController::class) ->patch('/reactions/{id}', 'reactions.update', Controller\UpdateReactionController::class) diff --git a/js/src/@types/shims.d.ts b/js/src/@types/shims.d.ts new file mode 100644 index 0000000..802941e --- /dev/null +++ b/js/src/@types/shims.d.ts @@ -0,0 +1,24 @@ +import PostReaction from '../forum/models/PostReaction'; +import Reaction from '../common/models/Reaction'; + +declare module 'flarum/common/models/Post' { + export default interface Post { + reactionCounts(): Record; + userReaction(): PostReaction | null; + + canReact(): boolean; + canDeletePostReactions(): boolean; + } +} + +declare module 'flarum/common/models/Discussion' { + export default interface Discussion { + canSeeReactions(): boolean; + } +} + +declare module 'flarum/forum/models/Forum' { + export default interface Forum { + reactions(): Reaction[]; + } +} diff --git a/js/src/admin/index.js b/js/src/admin/index.js index 6da9306..db3fdf7 100644 --- a/js/src/admin/index.js +++ b/js/src/admin/index.js @@ -34,5 +34,13 @@ app.initializers.add('fof/reactions', () => { }, 'view' ) + .registerPermission( + { + icon: 'fas fa-trash', + label: app.translator.trans('fof-reactions.admin.permissions.delete_post_reactions_label'), + permission: 'discussion.deletePostReactions', + }, + 'moderate' + ) .registerPage(SettingsPage); }); diff --git a/js/src/forum/components/ReactionsModal.tsx b/js/src/forum/components/ReactionsModal.tsx index 59f2daa..8696880 100644 --- a/js/src/forum/components/ReactionsModal.tsx +++ b/js/src/forum/components/ReactionsModal.tsx @@ -12,6 +12,7 @@ import Post from 'flarum/common/models/Post'; import { ApiResponsePlural } from 'flarum/common/Store'; import User from 'flarum/common/models/User'; import Reaction from '../../common/models/Reaction'; +import Button from 'flarum/common/components/Button'; interface ReactionsModalAttrs extends IInternalModalAttrs { post: Post; @@ -19,13 +20,14 @@ interface ReactionsModalAttrs extends IInternalModalAttrs { interface ReactionGroup { reaction: Reaction; - users: User[]; + users: Record; // map the post reaction id to the user anonymousCount: number; } export default class ReactionsModal extends Modal { reactions: ReactionGroup[] = []; loading: boolean = false; + deleting: Record = {}; className() { return 'ReactionsModal Modal--small'; @@ -58,7 +60,13 @@ export default class ReactionsModal extends Modal { ); } - buildReactionSection(reaction: Reaction, users: User[], anonymousCount: number): Mithril.Children { + buildReactionSection(reaction: Reaction, users: Record, anonymousCount: number): Mithril.Children { + const post = this.attrs.post; + + // The user can delete the reaction if they can delete reactions on the post, or + // if they can react (i.e. modify their reaction) and it's their own reaction + const canDeleteReaction = (user: User) => post.canDeletePostReactions() || (post.canReact() && user === app.session.user); + return (
@@ -68,12 +76,20 @@ export default class ReactionsModal extends Modal {
- {users.map((user: User) => ( -
  • + {Object.entries(users).map(([postReactionId, user]: [string, User], index: number) => ( +
  • {avatar(user, { loading: 'lazy' })} {username(user)} + {canDeleteReaction(user) && ( +
  • ))} @@ -97,7 +113,7 @@ export default class ReactionsModal extends Modal { continue; } - const users: User[] = []; + const users: Record = {}; let anonymousCount = 0; for (let reactionInstance of groupedReactions[reactionId]) { @@ -109,7 +125,7 @@ export default class ReactionsModal extends Modal { const user = app.store.getById('users', userId); if (user) { // Check for null user - users.push(user); + users[reactionInstance.id()!] = user; } } } @@ -122,4 +138,34 @@ export default class ReactionsModal extends Modal { m.redraw(); } + + async deletePostReaction(postReactionId: string, reactionId: string): Promise { + if (!postReactionId) return; + + this.deleting[postReactionId] = true; + + await app.request({ + method: 'DELETE', + url: `${app.forum.attribute('apiUrl')}/posts/${this.attrs.post.id()}/reactions/${postReactionId}`, + }); + + // Filter out the deleted reaction + const reaction = this.reactions.find((reaction) => reaction.reaction.id() === reactionId); + const postReaction = app.store.getById('post_reactions', postReactionId); + + if (reaction) { + delete reaction.users[postReactionId]; + + // Remove reaction group if there are no more reactions of this type + if (!Object.keys(reaction.users).length && !reaction.anonymousCount) { + this.reactions = this.reactions.filter((r) => r.reaction.id() !== reactionId); + } + } + + if (postReaction) app.store.remove(postReaction); + + delete this.deleting[postReactionId]; + + m.redraw(); + } } diff --git a/js/src/forum/index.js b/js/src/forum/index.js index e70a66f..b328b87 100755 --- a/js/src/forum/index.js +++ b/js/src/forum/index.js @@ -27,6 +27,7 @@ app.initializers.add('fof/reactions', () => { app.notificationComponents.postReacted = PostReactedNotification; Post.prototype.canReact = Model.attribute('canReact'); + Post.prototype.canDeletePostReactions = Model.attribute('canDeletePostReactions'); Post.prototype.reactionCounts = Model.attribute('reactionCounts'); Post.prototype.userReaction = Model.attribute('userReaction'); diff --git a/resources/less/forum.less b/resources/less/forum.less index 27ba90b..ec7e550 100644 --- a/resources/less/forum.less +++ b/resources/less/forum.less @@ -126,6 +126,14 @@ } } + li { + display: flex; + + .ReactionsModal-user { + flex: 1; + } + } + .ReactionsModal-group { margin-bottom: 40px; diff --git a/resources/locale/en.yml b/resources/locale/en.yml index 320b911..f56a244 100644 --- a/resources/locale/en.yml +++ b/resources/locale/en.yml @@ -16,12 +16,13 @@ fof-reactions: permissions: react_posts_label: React to posts see_reactions_label: See who reacted on posts + delete_post_reactions_label: Delete reactions on posts page: cdn: title: CDN help: | - By default, we serve the reaction assets from Cloudflare's CDN. You also have the option to specify any other CDN address + By default, we serve the reaction assets from Cloudflare's CDN. You also have the option to specify any other CDN address as required. [codepoint] will be substituted with the codepoint of the emoji. For example, to switch to Noto Emoji via jsdelivr, you would enter https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@v2.040/svg/emoji_u[codepoint].svg. This field must NEVER be left empty. diff --git a/src/Api/Controller/DeletePostReactionController.php b/src/Api/Controller/DeletePostReactionController.php new file mode 100644 index 0000000..f01c831 --- /dev/null +++ b/src/Api/Controller/DeletePostReactionController.php @@ -0,0 +1,36 @@ +getQueryParams(), 'id'); + $postReactionId = Arr::get($request->getQueryParams(), 'reactionId'); + + $post = Post::whereVisibleTo($actor)->findOrFail($postId); + $reaction = PostReaction::query()->where('post_id', $postId)->where('id', $postReactionId)->firstOrFail(); + + $actor->assertCan('react', $post); + + // If the post is not the actor's, they must have permission to delete reactions + if ($reaction->user_id !== $actor->id) { + $actor->assertCan('deletePostReactions', $post); + } + + $reaction->delete(); + + return new EmptyResponse(204); + } +} diff --git a/src/PostAttributes.php b/src/PostAttributes.php index 320a788..a9a0779 100644 --- a/src/PostAttributes.php +++ b/src/PostAttributes.php @@ -32,6 +32,7 @@ public function __invoke(PostSerializer $serializer, Post $post, array $attribut $actor = $serializer->getActor(); $attributes['canReact'] = (bool) $actor->can('react', $post); + $attributes['canDeletePostReactions'] = (bool) $actor->can('deletePostReactions', $post); // Get reaction counts for the post. $reactionCounts = $this->getReactionCountsForPost($post); From c1714d61a3fb29638b70d7edd9849ff439029251 Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Fri, 12 Jan 2024 21:32:14 -0500 Subject: [PATCH 3/9] Fix showing if user has voted for reaction on post --- js/src/@types/shims.d.ts | 2 +- js/src/forum/components/PostReactAction.js | 4 +--- js/src/forum/index.js | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/js/src/@types/shims.d.ts b/js/src/@types/shims.d.ts index 802941e..fc0b640 100644 --- a/js/src/@types/shims.d.ts +++ b/js/src/@types/shims.d.ts @@ -4,7 +4,7 @@ import Reaction from '../common/models/Reaction'; declare module 'flarum/common/models/Post' { export default interface Post { reactionCounts(): Record; - userReaction(): PostReaction | null; + userReaction(): number | undefined; canReact(): boolean; canDeletePostReactions(): boolean; diff --git a/js/src/forum/components/PostReactAction.js b/js/src/forum/components/PostReactAction.js index d8756c4..3109d42 100755 --- a/js/src/forum/components/PostReactAction.js +++ b/js/src/forum/components/PostReactAction.js @@ -77,9 +77,7 @@ export default class PostReactAction extends Component { return Button.component( { - className: `Button Button--flat Button-emoji-parent ${ - this.post.userReaction() && this.post.userReaction() == reaction.id() && 'active' - }`, + className: `Button Button--flat Button-emoji-parent ${this.post.userReaction() == reaction.id() && 'active'}`, onclick: canReact ? this.react.bind(this, reaction) : '', 'data-reaction': reaction.identifier(), disabled: !canReact, diff --git a/js/src/forum/index.js b/js/src/forum/index.js index b328b87..eb304ab 100755 --- a/js/src/forum/index.js +++ b/js/src/forum/index.js @@ -29,7 +29,7 @@ app.initializers.add('fof/reactions', () => { Post.prototype.canReact = Model.attribute('canReact'); Post.prototype.canDeletePostReactions = Model.attribute('canDeletePostReactions'); Post.prototype.reactionCounts = Model.attribute('reactionCounts'); - Post.prototype.userReaction = Model.attribute('userReaction'); + Post.prototype.userReaction = Model.attribute('userReactionIdentifier'); Forum.prototype.reactions = Model.hasMany('reactions'); From 687fc2e2e7c50085883fbcb39c1790e900dd3111 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sat, 13 Jan 2024 02:32:34 +0000 Subject: [PATCH 4/9] Apply fixes from StyleCI --- src/Api/Controller/DeletePostReactionController.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Api/Controller/DeletePostReactionController.php b/src/Api/Controller/DeletePostReactionController.php index f01c831..8ea80b7 100644 --- a/src/Api/Controller/DeletePostReactionController.php +++ b/src/Api/Controller/DeletePostReactionController.php @@ -1,5 +1,14 @@ Date: Sat, 13 Jan 2024 13:48:06 -0500 Subject: [PATCH 5/9] Allow deleting entire reaction types off posts --- extend.php | 3 +- js/src/forum/components/PostReactAction.js | 4 +- js/src/forum/components/ReactionsModal.tsx | 64 ++++++++++++++----- resources/less/forum.less | 12 ++++ resources/locale/en.yml | 1 + .../DeletePostReactionController.php | 34 ++++++++-- 6 files changed, 93 insertions(+), 25 deletions(-) diff --git a/extend.php b/extend.php index dd3d33a..ef85134 100644 --- a/extend.php +++ b/extend.php @@ -37,7 +37,8 @@ (new Extend\Routes('api')) ->get('/posts/{id}/reactions', 'post.reactions.index', Controller\ListPostReactionsController::class) - ->delete('/posts/{id}/reactions/{reactionId}', 'post.reactions.delete', Controller\DeletePostReactionController::class) + ->delete('/posts/{id}/reactions/specific/{postReactionId}', 'post.reactions.specific.delete', Controller\DeletePostReactionController::class) + ->delete('/posts/{id}/reactions/type/{reactionId}', 'post.reactions.type.delete', Controller\DeletePostReactionController::class) ->get('/reactions', 'reactions.index', Controller\ListReactionsController::class) ->post('/reactions', 'reactions.create', Controller\CreateReactionController::class) ->patch('/reactions/{id}', 'reactions.update', Controller\UpdateReactionController::class) diff --git a/js/src/forum/components/PostReactAction.js b/js/src/forum/components/PostReactAction.js index 3109d42..b96f3a3 100755 --- a/js/src/forum/components/PostReactAction.js +++ b/js/src/forum/components/PostReactAction.js @@ -63,6 +63,8 @@ export default class PostReactAction extends Component { const reactionCounts = this.post.reactionCounts(); const canReact = this.post.canReact(); + const hasReacted = this.post.userReaction() && reactionCounts[this.post.userReaction()] > 0; + return (
    @@ -90,7 +92,7 @@ export default class PostReactAction extends Component { })}
    - {(!Object.keys(this.loading).length || this.loading[null]) && !this.post.userReaction() && canReact && ( + {(!Object.keys(this.loading).length || this.loading[null]) && !hasReacted && canReact && (
    {this.reactButton()} diff --git a/js/src/forum/components/ReactionsModal.tsx b/js/src/forum/components/ReactionsModal.tsx index 8696880..25b368b 100644 --- a/js/src/forum/components/ReactionsModal.tsx +++ b/js/src/forum/components/ReactionsModal.tsx @@ -27,7 +27,9 @@ interface ReactionGroup { export default class ReactionsModal extends Modal { reactions: ReactionGroup[] = []; loading: boolean = false; - deleting: Record = {}; + + deletingSpecific: Record = {}; + deletingType: Record = {}; className() { return 'ReactionsModal Modal--small'; @@ -55,6 +57,8 @@ export default class ReactionsModal extends Modal {
      {this.reactions.map(({ reaction, users, anonymousCount }) => this.buildReactionSection(reaction, users, anonymousCount))} + + {!this.reactions.length &&

      {app.translator.trans('fof-reactions.forum.modal.no_reactions')}

      }
    ); @@ -72,6 +76,14 @@ export default class ReactionsModal extends Modal { + {post.canDeletePostReactions() && ( +