Commit 542ec44d authored by Emma's avatar Emma 🦉

refactor messages/add message deletion

parent c4275c3e
Pipeline #54516953 passed with stage
in 1 minute and 5 seconds
......@@ -13,9 +13,6 @@
& > .body {
grid-area: body;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
margin: 1rem;
}
......
@import (reference) '_mixins';
.message-thread-inner,
.message-reply {
.message {
border: solid 1px var(--border);
margin-bottom: 1rem;
padding: 1rem 1rem 0;
}
.message-head {
.meta();
font-weight: normal;
}
&__head {
.meta();
font-weight: normal;
}
&__buttons {
.meta-nav();
}
}
......@@ -8,6 +8,10 @@ $(function () {
return confirm(Translator.trans('prompt.confirm_comment_delete'));
});
$('.js-confirm-message-delete').click(() => {
return confirm(Translator.trans('prompt.confirm_message_delete'));
});
$('.confirm-submission-delete').click(function () {
return confirm(Translator.trans('prompt.confirm_submission_delete'));
});
......
message_list:
controller: App\Controller\MessageController::list
message_threads:
controller: App\Controller\MessageController::threads
defaults: { page: 1 }
path: /messages/{page}
methods: [GET]
requirements: { page: \d+ }
message:
controller: App\Controller\MessageController::message
path: /message/{id}
message_thread:
controller: App\Controller\MessageController::thread
path: /messages/thread/{id}
methods: [GET]
requirements: { id: "%number_regex%" }
compose_message:
controller: App\Controller\MessageController::compose
path: /compose_message/{username}
path: /user/{username}/compose_message
methods: [GET, POST]
requirements: { username: \w+ }
compose_message_legacy_redirect:
controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
defaults: { route: compose_message }
path: /compose_message/{username}
methods: [GET]
requirements: { username: \w+ }
reply_to_message:
controller: App\Controller\MessageController::reply
path: /message_reply/{id}
methods: [POST]
requirements: { id: "%number_regex%" }
delete_message:
controller: App\Controller\MessageController::delete
path: /messages/message/{id}/delete
methods: [POST]
requirements: { id: "%uuid_regex%" }
......@@ -4,7 +4,7 @@ namespace App\Command;
use App\Entity\Comment;
use App\Entity\CommentVote;
use App\Entity\MessageReply;
use App\Entity\Message;
use App\Entity\MessageThread;
use App\Entity\Submission;
use App\Entity\SubmissionVote;
......@@ -94,8 +94,7 @@ class PruneIpAddressesCommand extends Command {
$count += $this->clearIpsForEntity(CommentVote::class, $maxTime);
$count += $this->clearIpsForEntity(Submission::class, $maxTime);
$count += $this->clearIpsForEntity(SubmissionVote::class, $maxTime);
$count += $this->clearIpsForEntity(MessageThread::class, $maxTime);
$count += $this->clearIpsForEntity(MessageReply::class, $maxTime);
$count += $this->clearIpsForEntity(Message::class, $maxTime);
if ($input->getOption('dry-run')) {
$this->manager->rollback();
......
......@@ -2,10 +2,10 @@
namespace App\Controller;
use App\Entity\Message;
use App\Entity\MessageThread;
use App\Entity\User;
use App\Form\MessageReplyType;
use App\Form\MessageThreadType;
use App\Form\MessageType;
use App\Form\Model\MessageData;
use App\Repository\MessageThreadRepository;
use Doctrine\ORM\EntityManager;
......@@ -14,27 +14,27 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @IsGranted("ROLE_USER")
*/
final class MessageController extends AbstractController {
/**
* @IsGranted("ROLE_USER")
*
* @param MessageThreadRepository $repository
* @param int $page
*
* @return Response
*/
public function list(MessageThreadRepository $repository, int $page) {
$messages = $repository->findUserMessages($this->getUser(), $page);
public function threads(MessageThreadRepository $repository, int $page) {
$messageThreads = $repository->findUserMessages($this->getUser(), $page);
return $this->render('message/list.html.twig', [
'messages' => $messages,
return $this->render('message/threads.html.twig', [
'threads' => $messageThreads,
]);
}
/**
* Start a new message thread.
*
* @IsGranted("ROLE_USER")
* @IsGranted("message", subject="receiver", statusCode=403)
* @Entity("receiver", expr="repository.findOneOrRedirectToCanonical(username, 'username')")
*
......@@ -45,46 +45,45 @@ final class MessageController extends AbstractController {
* @return Response
*/
public function compose(Request $request, EntityManager $em, User $receiver) {
$data = new MessageData($this->getUser(), $request->getClientIp());
$data = new MessageData();
$form = $this->createForm(MessageThreadType::class, $data);
$form = $this->createForm(MessageType::class, $data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$thread = $data->toThread($receiver);
$thread = $data->toThread($this->getUser(), $receiver, $request->getClientIp());
$em->persist($thread);
$em->flush();
return $this->redirectToRoute('message', [
return $this->redirectToRoute('message_thread', [
'id' => $thread->getId(),
]);
}
return $this->render('message/compose.html.twig', [
'form' => $form->createView(),
'receiver' => $receiver,
'user' => $receiver,
]);
}
/**
* View a message thread.
*
* @IsGranted("ROLE_USER")
* @IsGranted("access", subject="thread", statusCode=403)
*
* @param MessageThread $thread
*
* @return Response
*/
public function message(MessageThread $thread) {
return $this->render('message/message.html.twig', [
public function thread(MessageThread $thread) {
return $this->render('message/thread.html.twig', [
'thread' => $thread,
]);
}
public function replyForm($threadId) {
$form = $this->createForm(MessageReplyType::class, null, [
$form = $this->createForm(MessageType::class, null, [
'action' => $this->generateUrl('reply_to_message', [
'id' => $threadId,
]),
......@@ -96,8 +95,7 @@ final class MessageController extends AbstractController {
}
/**
* @IsGranted("ROLE_USER")
* @IsGranted("reply", subject="thread", statusCode=40333)
* @IsGranted("reply", subject="thread", statusCode=403)
*
* @param Request $request
* @param EntityManager $em
......@@ -106,17 +104,18 @@ final class MessageController extends AbstractController {
* @return Response
*/
public function reply(Request $request, EntityManager $em, MessageThread $thread) {
$data = new MessageData($this->getUser(), $request->getClientIp());
$data = new MessageData();
$form = $this->createForm(MessageReplyType::class, $data);
$form = $this->createForm(MessageType::class, $data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$thread->addReply($data->toReply($thread));
$message = $data->toMessage($thread, $this->getUser(), $request->getClientIp());
$thread->addMessage($message);
$em->flush();
return $this->redirectToRoute('message', [
return $this->redirectToRoute('message_thread', [
'id' => $thread->getId(),
]);
}
......@@ -126,4 +125,38 @@ final class MessageController extends AbstractController {
'thread' => $thread,
]);
}
/**
* @IsGranted("delete", subject="message", statusCode=403)
*
* @param Request $request
* @param EntityManager $em
* @param Message $message
*
* @return Response
*/
public function delete(Request $request, EntityManager $em, Message $message) {
$this->validateCsrf('delete_message', $request->request->get('token'));
$em->refresh($message);
$thread = $message->getThread();
$thread->removeMessage($message);
if (\count($thread->getMessages()) === 0) {
$em->remove($thread);
$threadRemove = true;
}
$em->flush();
if ($threadRemove ?? false) {
return $this->redirectToRoute('message_threads');
}
return $this->redirectToRoute('message_thread', [
'id' => $thread->getId(),
]);
}
}
......@@ -3,20 +3,30 @@
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
/**
* @ORM\MappedSuperclass()
* @ORM\Entity()
* @ORM\Table(name="messages")
*/
abstract class Message {
class Message {
/**
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue()
* @ORM\Column(type="uuid")
* @ORM\Id()
*
* @var int|null
* @var UuidInterface
*/
private $id;
/**
* @ORM\JoinColumn(nullable=false)
* @ORM\ManyToOne(targetEntity="MessageThread", inversedBy="messages", cascade={"persist"})
*
* @var MessageThread
*/
private $thread;
/**
* @ORM\JoinColumn(nullable=false)
* @ORM\ManyToOne(targetEntity="User")
......@@ -46,21 +56,35 @@ abstract class Message {
*/
private $ip;
public function __construct(User $sender, string $body, ?string $ip) {
/**
* @ORM\OneToMany(targetEntity="MessageNotification", mappedBy="message", cascade={"remove"})
*/
private $notifications;
public function __construct(MessageThread $thread, User $sender, string $body, ?string $ip) {
if ($ip !== null && !filter_var($ip, FILTER_VALIDATE_IP)) {
throw new \InvalidArgumentException('$ip must be valid IP address or NULL');
}
$this->id = Uuid::uuid4();
$this->thread = $thread;
$this->sender = $sender;
$this->body = $body;
$this->ip = $sender->isTrustedOrAdmin() ? null : $ip;
$this->timestamp = new \DateTime('@'.time());
$this->notify();
$thread->addMessage($this);
}
public function getId(): ?int {
public function getId(): UuidInterface {
return $this->id;
}
public function getThread(): MessageThread {
return $this->thread;
}
public function getSender(): User {
return $this->sender;
}
......@@ -76,4 +100,15 @@ abstract class Message {
public function getIp(): ?string {
return $this->ip;
}
private function notify() {
foreach ($this->thread->getParticipants() as $user) {
if ($user === $this->sender || $user->isBlocking($this->sender)) {
// don't notify self or users blocking you
continue;
}
$user->sendNotification(new MessageNotification($user, $this));
}
}
}
......@@ -6,26 +6,27 @@ use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="messages")
*/
class MessageThreadNotification extends Notification {
class MessageNotification extends Notification {
/**
* @ORM\ManyToOne(targetEntity="MessageThread", inversedBy="notifications")
* @ORM\ManyToOne(targetEntity="Message", inversedBy="notifications", cascade={"persist"})
*
* @var MessageThread
* @var Message
*/
private $thread;
private $message;
public function __construct(User $receiver, MessageThread $thread) {
public function __construct(User $receiver, Message $message) {
parent::__construct($receiver);
$this->thread = $thread;
$this->message = $message;
}
public function getThread(): MessageThread {
return $this->thread;
public function getMessage(): Message {
return $this->message;
}
public function getType(): string {
return 'message_thread';
return 'message';
}
}
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="message_replies")
*/
class MessageReply extends Message {
/**
* @ORM\JoinColumn(nullable=false)
* @ORM\ManyToOne(targetEntity="MessageThread", inversedBy="replies")
*
* @var MessageThread
*/
private $thread;
/**
* @ORM\OneToMany(targetEntity="MessageReplyNotification", mappedBy="reply", cascade={"remove"})
*/
private $notifications;
public function __construct(User $sender, string $body, ?string $ip, MessageThread $thread) {
parent::__construct($sender, $body, $ip);
$this->thread = $thread;
$this->notify();
$this->notifications = null; // remove unused field warning
}
public function getThread(): MessageThread {
return $this->thread;
}
public function notify() {
if ($this->getSender() === $this->thread->getSender()) {
$receiver = $this->thread->getReceiver();
} else {
$receiver = $this->thread->getSender();
}
$receiver->sendNotification(new MessageReplyNotification($receiver, $this));
}
}
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
class MessageReplyNotification extends Notification {
// TODO: figure out why does this requires cascade={"persist"} while thread
// notifications don't.
/**
* @ORM\ManyToOne(targetEntity="MessageReply", inversedBy="notifications", cascade={"persist"})
*
* @var MessageReply
*/
private $reply;
public function __construct(User $receiver, MessageReply $reply) {
parent::__construct($receiver);
$this->reply = $reply;
}
public function getReply(): MessageReply {
return $this->reply;
}
public function getType(): string {
return 'message_reply';
}
}
......@@ -11,86 +11,92 @@ use Doctrine\ORM\Mapping as ORM;
* @ORM\Entity(repositoryClass="App\Repository\MessageThreadRepository")
* @ORM\Table(name="message_threads")
*/
class MessageThread extends Message {
class MessageThread {
/**
* @ORM\JoinColumn(nullable=false)
* @ORM\ManyToOne(targetEntity="User")
*
* @var User
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue()
* @ORM\Id()
*/
private $receiver;
private $id;
/**
* @ORM\OneToMany(targetEntity="MessageReply", mappedBy="thread", cascade={"persist", "remove"})
* @ORM\OrderBy({"id": "ASC"})
* @ORM\JoinTable(name="message_thread_participants", joinColumns={
* @ORM\JoinColumn(name="message_thread_id", referencedColumnName="id")
* }, inverseJoinColumns={
* @ORM\JoinColumn(name="user_id", referencedColumnName="id")
* })
* @ORM\ManyToMany(targetEntity="User")
*
* @var MessageReply[]|Collection|Selectable
* @var User[]|Collection|Selectable
*/
private $replies;
private $participants;
/**
* @ORM\Column(type="text")
* @ORM\OneToMany(targetEntity="Message", mappedBy="thread",
* cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OrderBy({"timestamp": "ASC"})
*
* @var string
*/
private $title;
/**
* @ORM\OneToMany(targetEntity="MessageThreadNotification", mappedBy="thread", cascade={"remove"})
* @var Message[]|Collection|Selectable
*/
private $notifications;
private $messages;
public function __construct(User $sender, string $body, ?string $ip, User $receiver, string $title) {
if (!$receiver->canBeMessagedBy($sender)) {
throw new \DomainException('$sender cannot message $receiver');
public function __construct(User $sender, User ...$participants) {
foreach ($participants as $participant) {
if (!$participant->canBeMessagedBy($sender)) {
throw new \DomainException('Sender cannot message one of the participants');
}
}
parent::__construct($sender, $body, $ip);
$participants[] = $sender;
$this->receiver = $receiver;
$this->replies = new ArrayCollection();
$this->title = $title;
$this->notify();
$this->notifications = null; // remove unused field warning
$this->participants = new ArrayCollection($participants);
$this->messages = new ArrayCollection();
}
public function getReceiver(): User {
return $this->receiver;
public function getId(): ?int {
return $this->id;
}
public function getTitle(): string {
return $this->title;
/**
* @return User[]|Collection|Selectable
*/
public function getParticipants(): Collection {
return $this->participants;
}
public function userIsParticipant($user): bool {
return $this->participants->contains($user);
}
/**
* @return MessageReply[]|Collection|Selectable
* @return Message[]|Collection|Selectable
*/
public function getReplies() {
return $this->replies;
public function getMessages(): Collection {
return $this->messages;
}
public function addReply(MessageReply $reply) {
if (!$this->userCanReply($reply->getSender())) {
throw new \DomainException('Sender is not allowed to reply');
}
public function addMessage(Message $message): void {
if (!$this->messages->contains($message)) {
if (!$this->userIsParticipant($message->getSender())) {
throw new \DomainException('Sender is not allowed to participate');
}
if (!$this->replies->contains($reply)) {
$this->replies->add($reply);
$this->messages->add($message);
}
}
public function userCanAccess($user): bool {
return $user === $this->receiver || $user === $this->getSender();
public function removeMessage(Message $message): void {
$this->messages->removeElement($message);
}
public function userCanReply($user): bool {
return $user === $this->receiver && $this->getSender()->canBeMessagedBy($user) ||
$user === $this->getSender() && $this->receiver->canBeMessagedBy($user);
}
public function getTitle(): string {
$body = $this->messages[0]->getBody();
$firstLine = preg_replace('/^# |\R.*/', '', $body);
private function notify() {
$notification = new MessageThreadNotification($this->receiver, $this);
if (\grapheme_strlen($firstLine) <= 100) {
return $firstLine;
}
$this->receiver->sendNotification($notification);
return \grapheme_substr($firstLine, 0, 100).'…';
}
}
......@@ -12,8 +12,7 @@ use Doctrine\ORM\Mapping as ORM;
* @ORM\DiscriminatorMap({
* "comment": "CommentNotification",
* "comment_mention": "CommentMention",
* "message_thread": "MessageThreadNotification",
* "message_reply": "MessageReplyNotification",
* "message": "MessageNotification",
* "submission_mention": "SubmissionMention",
* })
*/
......
<?php
namespace App\Form;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MessageThreadType extends MessageReplyType {
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder->add('title', TextareaType::class);
parent::buildForm($builder, $options);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver) {
parent::configureOptions($resolver);
$resolver->setDefault('validation_groups', ['thread']);
}
}
......@@ -3,34 +3,22 @@
namespace App\Form;
use App\Form\Model\MessageData;
use App\Form\Type\MarkdownType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MessageReplyType extends AbstractType {
/**
* {@inheritdoc}
*/
final class MessageType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('body', MarkdownType::class, [
->add('body', TextareaType::class, [
'label' => 'message_form.message',
])
->add('submit', SubmitType::class, [
'label' => 'message_form.reply',
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver) {
$resolver->setDefaults([
'data_class' => MessageData::class,
'label_format' => 'message_form.%name%',
'validation_groups' => ['reply'],
]);
}
}
......@@ -2,43 +2,20 @@
namespace App\Form\Model;
use App\Entity\MessageReply;
use App\Entity\Message;
use App\Entity\MessageThread;
use App\Entity\User;
use Symfony\Component\Validator\Constraints as Assert;
class MessageData {
/**