Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reactions list modal improvements #81

Merged
merged 9 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ insert_final_newline = true
indent_style = space
indent_size = 4
[*.md]
indent_size = 2
trim_trailing_whitespace = false
trim_trailing_whitespace = false
[*.{.md,js}]
indent_size = 2
2 changes: 2 additions & 0 deletions extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@

(new Extend\Routes('api'))
->get('/posts/{id}/reactions', 'post.reactions.index', Controller\ListPostReactionsController::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)
Expand Down
24 changes: 24 additions & 0 deletions js/src/@types/shims.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
userReaction(): number | undefined;

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[];
}
}
8 changes: 8 additions & 0 deletions js/src/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.deleteReactionsPosts',
},
'moderate'
)
.registerPage(SettingsPage);
});
4 changes: 2 additions & 2 deletions js/src/common/components/ReactionComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export default class ReactionComponent extends Component {
if (reaction.type() === 'emoji') {
const { url } = emoji(reaction.identifier());

return <img className={className} src={url} loading="lazy" draggable="false" alt={display} {...attrs} />;
return <img className={classList(className, 'emoji')} src={url} loading="lazy" draggable="false" alt={display} {...attrs} />;
} else {
return <i className={classList(className, reaction.identifier())} aria-hidden {...attrs} />;
return <i className={classList(className, reaction.identifier(), 'icon')} aria-hidden {...attrs} />;
}
}
}
8 changes: 4 additions & 4 deletions js/src/forum/components/PostReactAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div style="margin-right: 7px" className="Reactions">
<div className="Reactions--reactions">
Expand All @@ -77,9 +79,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,
Expand All @@ -92,7 +92,7 @@ export default class PostReactAction extends Component {
})}
</div>

{(!Object.keys(this.loading).length || this.loading[null]) && !this.post.userReaction() && canReact && (
{(!Object.keys(this.loading).length || this.loading[null]) && !hasReacted && canReact && (
<div className="Reactions--react">
{this.reactButton()}

Expand Down
90 changes: 84 additions & 6 deletions js/src/forum/components/ReactionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,25 @@ 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;
}

interface ReactionGroup {
reaction: Reaction;
users: User[];
users: Record<string, User>; // map the post reaction id to the user
anonymousCount: number;
}

export default class ReactionsModal extends Modal<ReactionsModalAttrs> {
reactions: ReactionGroup[] = [];
loading: boolean = false;

deletingSpecific: Record<string, boolean> = {};
deletingType: Record<string, boolean> = {};

className() {
return 'ReactionsModal Modal--small';
}
Expand All @@ -53,27 +57,51 @@ export default class ReactionsModal extends Modal<ReactionsModalAttrs> {
<div className="Modal-body">
<ul className="ReactionsModal-list">
{this.reactions.map(({ reaction, users, anonymousCount }) => this.buildReactionSection(reaction, users, anonymousCount))}

{!this.reactions.length && <p>{app.translator.trans('fof-reactions.forum.modal.no_reactions')}</p>}
</ul>
</div>
);
}

buildReactionSection(reaction: Reaction, users: User[], anonymousCount: number): Mithril.Children {
buildReactionSection(reaction: Reaction, users: Record<string, User>, 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 (
<div className="ReactionsModal-group">
<legend>
<ReactionComponent reaction={reaction} className={'ReactionModal-reaction'} />
<label className="ReactionsModal-display">{reaction.display() || reaction.identifier()}</label>
{post.canDeletePostReactions() && (
<Button
icon="fas fa-minus-circle"
className="Button Button--icon Button--link"
loading={this.deletingType[reaction.id()!]}
onclick={this.deletePostReaction.bind(this, false, reaction.id()!)}
/>
)}
</legend>

<hr className="ReactionsModal-delimiter" />

{users.map((user: User) => (
<li key={user.id()}>
{Object.entries(users).map(([postReactionId, user]: [string, User], index: number) => (
<li key={user.id()} data-post-reaction-id={postReactionId} data-user-id={user.id()}>
<Link className="ReactionsModal-user" href={app.route.user(user)}>
{avatar(user, { loading: 'lazy' })}
{username(user)}
</Link>
{canDeleteReaction(user) && (
<Button
icon="fas fa-minus-circle"
className="Button Button--icon Button--link"
loading={this.deletingSpecific[postReactionId]}
onclick={this.deletePostReaction.bind(this, postReactionId, reaction.id()!)}
/>
)}
</li>
))}

Expand All @@ -97,7 +125,7 @@ export default class ReactionsModal extends Modal<ReactionsModalAttrs> {
continue;
}

const users: User[] = [];
const users: Record<string, User> = {};
let anonymousCount = 0;

for (let reactionInstance of groupedReactions[reactionId]) {
Expand All @@ -109,7 +137,7 @@ export default class ReactionsModal extends Modal<ReactionsModalAttrs> {
const user = app.store.getById<User>('users', userId);
if (user) {
// Check for null user
users.push(user);
users[reactionInstance.id()!] = user;
}
}
}
Expand All @@ -122,4 +150,54 @@ export default class ReactionsModal extends Modal<ReactionsModalAttrs> {

m.redraw();
}

async deletePostReaction(postReactionId: string | false, reactionId: string): Promise<void> {
const isSpecific = postReactionId !== false;
const loadingArr = isSpecific ? this.deletingSpecific : this.deletingType;
const id = isSpecific ? postReactionId : reactionId;

loadingArr[id] = true;

await app.request({
method: 'DELETE',
url: `${app.forum.attribute('apiUrl')}/posts/${this.attrs.post.id()}/reactions/${isSpecific ? 'specific' : 'type'}/${id}`,
});

// Filter out the deleted reaction type
const reaction = this.reactions.find((reaction) => reaction.reaction.id() === reactionId);

if (isSpecific) {
// Remove only the specific post_reaction
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);

this.attrs.post.reactionCounts()[reactionId]--;
} else {
// Remove all reactions of this type
this.reactions = this.reactions.filter((r) => r.reaction.id() !== reactionId);

if (reaction) {
for (const postReactionId in reaction.users) {
const postReaction = app.store.getById('post_reactions', postReactionId);
if (postReaction) app.store.remove(postReaction);
}
}

this.attrs.post.reactionCounts()[reactionId] = 0;
}

delete loadingArr[id];

m.redraw();
}
}
3 changes: 2 additions & 1 deletion js/src/forum/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ 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');
Post.prototype.userReaction = Model.attribute('userReactionIdentifier');

Forum.prototype.reactions = Model.hasMany('reactions');

Expand Down
20 changes: 20 additions & 0 deletions resources/less/forum.less
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,29 @@
}
}

li {
display: flex;

.ReactionsModal-user {
flex: 1;
}
}

.ReactionsModal-group {
margin-bottom: 40px;

legend {
display: flex;

label {
flex: 1;
}

.Button {
padding: 2px 0;
}
}

.ReactionsModal-reaction {
.emoji {
height: 24px;
Expand Down
4 changes: 3 additions & 1 deletion resources/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ fof-reactions:
modal:
title: Reactions
anonymous_count: "{count, plural, one {# anonymous user} other {# anonymous users}}"
no_reactions: No reactions yet
mod_item: View Reactions
react_button_label: React

admin:
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. <code>[codepoint]</code> will be substituted with the codepoint of the emoji.

For example, to switch to Noto Emoji via jsdelivr, you would enter <code>https://cdn.jsdelivr.net/gh/googlefonts/[email protected]/svg/emoji_u[codepoint].svg</code>. This field must NEVER be left empty.
Expand Down
67 changes: 67 additions & 0 deletions src/Api/Controller/DeletePostReactionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/*
* This file is part of fof/reactions.
*
* Copyright (c) FriendsOfFlarum.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace FoF\Reactions\Api\Controller;

use Flarum\Api\Controller\AbstractDeleteController;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Flarum\User\Exception\PermissionDeniedException;
use FoF\Reactions\PostAnonymousReaction;
use FoF\Reactions\PostReaction;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ServerRequestInterface;

class DeletePostReactionController extends AbstractDeleteController
{
/**
* @throws PermissionDeniedException
*/
protected function delete(ServerRequestInterface $request): EmptyResponse
{
$actor = RequestUtil::getActor($request);
$params = $request->getQueryParams();

$postId = Arr::get($params, 'id');
$postReactionId = Arr::get($params, 'postReactionId');
$reactionId = Arr::get($params, 'reactionId');

$post = Post::whereVisibleTo($actor)->findOrFail($postId);

if ($reactionId) {
// Delete all post_reactions of a specific type (i.e. `reaction_id`)
$actor->assertCan('deleteReactions', $post);

PostReaction::query()->where('post_id', $postId)->where('reaction_id', $reactionId)->delete();
PostAnonymousReaction::query()->where('post_id', $postId)->where('reaction_id', $reactionId)->delete();
} elseif ($postReactionId) {
// Delete a specific post_reaction for the post
/**
* @var PostReaction $reaction
*/
$reaction = PostReaction::query()->where('post_id', $postId)->where('id', $postReactionId)->firstOrFail();

// If the post is not the actor's, they must have permission to delete reactions
if ($reaction->user_id != $actor->id) {
$actor->assertCan('deleteReactions', $post);
} else {
$actor->assertCan('react', $post);
}

$reaction->delete();
}

// TODO should this send pusher updates? would need new type for non-specific, otherwise could spam pusher events

return new EmptyResponse(204);
}
}
1 change: 1 addition & 0 deletions src/PostAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('deleteReactions', $post);

// Get reaction counts for the post.
$reactionCounts = $this->getReactionCountsForPost($post);
Expand Down
Loading
Loading