mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
redo message actions
This commit is contained in:
1 parent
1ff18f0c82
commit
ae9bb8b757
13 files changed
+429
-43
No files matched your search
@@ -1,7 +1,21 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* Handles per-row status actions in the message list.
|
||||
*
|
||||
* Values:
|
||||
* id — entity ID (thread or message)
|
||||
* type — "thread" (default) | "message"
|
||||
*
|
||||
* Routes used:
|
||||
* thread → /thread/{id}/status/{action}
|
||||
* message → /message/{id}/status/{action}
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = { id: Number };
|
||||
static values = {
|
||||
id: Number,
|
||||
type: { type: String, default: "thread" },
|
||||
};
|
||||
|
||||
stop(event) {
|
||||
event.stopPropagation();
|
||||
@@ -10,9 +24,6 @@ export default class extends Controller {
|
||||
toggleSelect(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
// Notify the toolbar controller so it can sync the master checkbox
|
||||
// and show/hide the action bar. We bubble up through the DOM until
|
||||
// we find the element that hosts list-toolbar, then dispatch there.
|
||||
const toolbar = document.querySelector("[data-controller~='list-toolbar']");
|
||||
|
||||
if (toolbar) {
|
||||
@@ -23,13 +34,15 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async toggleStar(event) {
|
||||
const { starUrl } = event.params;
|
||||
event.stopPropagation();
|
||||
await this.#post(this.#url("star"));
|
||||
await this.#post(starUrl);
|
||||
}
|
||||
|
||||
async archive(event) {
|
||||
const { archiveUrl } = event.params;
|
||||
event.stopPropagation();
|
||||
await this.#post(this.#url("archive"));
|
||||
await this.#post(archiveUrl);
|
||||
}
|
||||
|
||||
async delete(event) {
|
||||
@@ -39,20 +52,40 @@ export default class extends Controller {
|
||||
|
||||
async snooze(event, until = null) {
|
||||
event.stopPropagation();
|
||||
await this.#post(this.#url("snooze"), { until });
|
||||
|
||||
// Snooze is always thread-level.
|
||||
const url = this.typeValue === "thread"
|
||||
? this.#url("snooze")
|
||||
: `/thread/${this.idValue}/status/snooze`;
|
||||
|
||||
await this.#post(url, { until });
|
||||
}
|
||||
|
||||
async markRead(event) {
|
||||
event.stopPropagation();
|
||||
const { read } = event.params;
|
||||
|
||||
await this.#post(this.#url("read"), { read });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- private
|
||||
async trash(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
// Separate from delete: moves to trash rather than permanently removing.
|
||||
// For thread rows this calls the delete route (which internally decides
|
||||
// trash vs. permanent based on current mailbox).
|
||||
// For message rows it calls the dedicated trash route.
|
||||
const url = this.typeValue === "message"
|
||||
? this.#url("trash")
|
||||
: this.#url("delete");
|
||||
|
||||
await this.#post(url);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
#url(action) {
|
||||
return `/thread/${this.idValue}/status/${action}`;
|
||||
const base = this.typeValue === "message" ? "message" : "thread";
|
||||
return `/${base}/${this.idValue}/status/${action}`;
|
||||
}
|
||||
|
||||
async #post(url, body = {}) {
|
||||
@@ -66,11 +99,14 @@ export default class extends Controller {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Thread status update failed: ${url}`, response.status);
|
||||
console.error(`[message-row] status update failed: ${url}`, response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
Turbo.renderStreamMessage(html);
|
||||
|
||||
if (html.trim() !== "") {
|
||||
Turbo.renderStreamMessage(html);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,4 @@ framework:
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
App\Message\SyncMailboxMessage: async
|
||||
App\Message\SendMessageMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
App\Message\ApplyImapFlagsMessage: async
|
||||
@@ -2,55 +2,85 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\Enum\MailboxSpecialUse;
|
||||
use App\Domain\Enum\MessageFlag;
|
||||
use App\Entity\Message;
|
||||
use App\Entity\MessageThread;
|
||||
use App\Entity\User;
|
||||
use App\Message\ApplyImapFlagsMessage;
|
||||
use App\Repository\MailboxRepository;
|
||||
use App\Repository\MessageRepository;
|
||||
use App\Repository\MessageThreadRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use function Sodium\add;
|
||||
|
||||
#[Route('/thread/{thread}/status', name: 'app_thread_status_')]
|
||||
#[Route('/status/{type}/{id}', name: 'app_status_')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ThreadStatusController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MessageRepository $messageRepository,
|
||||
) {}
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MessageRepository $messageRepository,
|
||||
private readonly MessageThreadRepository $threadRepository,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly MailboxRepository $mailboxRepository,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/star', name: 'star', methods: ['POST'])]
|
||||
public function star(MessageThread $thread): Response
|
||||
public function star(string $type, int $id): Response
|
||||
{
|
||||
$this->assertOwnership($thread);
|
||||
$messages = $this->resolveMessages($type, $id);
|
||||
|
||||
$thread->setStarredAt(
|
||||
$thread->getStarredAt() === null ? new DateTimeImmutable() : null
|
||||
);
|
||||
$message = $messages[0];
|
||||
|
||||
$action = 'flag';
|
||||
if ($message->getStarredAt() === null) {
|
||||
$message
|
||||
->addFlag(MessageFlag::FLAGGED)
|
||||
->setStarredAt(new DateTimeImmutable());
|
||||
$message->getThread()->setStarredAt(new DateTimeImmutable());
|
||||
} else {
|
||||
$action = 'unflag';
|
||||
$message->removeFlag(MessageFlag::FLAGGED)
|
||||
->setStarredAt(null);
|
||||
$message->getThread()->setStarredAt(null);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$this->dispatchImapAction($messages, $action);
|
||||
|
||||
return $this->renderTurboStream('thread/status/_star.stream.html.twig', [
|
||||
'thread' => $thread,
|
||||
$type => 'message' === $type ? $message : $message->getThread(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/archive', name: 'archive', methods: ['POST'])]
|
||||
public function archive(MessageThread $thread): Response
|
||||
public function archive(string $type, int $id): Response
|
||||
{
|
||||
$this->assertOwnership($thread);
|
||||
$messages = $this->resolveMessages($type, $id);
|
||||
|
||||
$thread->setArchivedAt(
|
||||
$thread->isArchived() ? null : new DateTimeImmutable()
|
||||
);
|
||||
$archiveMailbox = $this->mailboxRepository->findArchiveMailboxForAccount($messages[0]->getMailbox()->getAccount());
|
||||
if(null === $archiveMailbox){
|
||||
return $this->renderTurboStream('_toasts/generic.html.twig', ['type' => 'error', 'message' => 'toast.error.no_archive_mailbox']);
|
||||
}
|
||||
// $thread->setArchivedAt(
|
||||
// $thread->isArchived() ? null : new DateTimeImmutable()
|
||||
// );
|
||||
$this->em->flush();
|
||||
|
||||
//move to archive
|
||||
return $this->renderTurboStream('thread/status/_archive.stream.html.twig', [
|
||||
'thread' => $thread,
|
||||
$type => 'message' === $type ? $messages[0] : $messages[0]->getThread(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -115,20 +145,63 @@ class ThreadStatusController extends AbstractController
|
||||
$this->em->flush();
|
||||
|
||||
return $this->renderTurboStream('thread/status/_read.stream.html.twig', [
|
||||
'thread' => $thread,
|
||||
'thread' => $thread,
|
||||
'markAsRead' => $markAsRead,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
private function assertOwnership(MessageThread $thread): void
|
||||
/**
|
||||
* @return iterable<Message>
|
||||
*/
|
||||
private function resolveMessages(string $type, int $id): iterable
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$messages = [];
|
||||
|
||||
if ($thread->getAccount()->getUsr() !== $user) {
|
||||
throw $this->createAccessDeniedException('This thread does not belong to you.');
|
||||
if ('message' === $type) {
|
||||
$messages = [$this->messageRepository->find($id)];
|
||||
}
|
||||
|
||||
if ('thread' === $type) {
|
||||
$messages = $this->threadRepository->find($id)->getMessages();
|
||||
}
|
||||
|
||||
$this->assertOwnership($messages);
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Message> $messages
|
||||
*/
|
||||
private function dispatchImapAction(iterable $messages, string $action): void
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($messages as $message) {
|
||||
if(null === $message->getImapUid()){
|
||||
continue;
|
||||
}
|
||||
|
||||
$ids[] = $message->getId();
|
||||
}
|
||||
|
||||
if (count($ids) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->bus->dispatch(new ApplyImapFlagsMessage($ids, $action));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Message> $messages
|
||||
*/
|
||||
private function assertOwnership(iterable $messages): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
if ($message->getMailbox()->getAccount()->getUsr() !== $this->getUser()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,4 +211,5 @@ class ThreadStatusController extends AbstractController
|
||||
headers: ['Content-Type' => 'text/vnd.turbo-stream.html'],
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,4 +9,5 @@ enum MailboxSpecialUse: string
|
||||
case TRASH = '\\Trash';
|
||||
case DRAFTS = '\\Drafts';
|
||||
case JUNK = '\\Junk';
|
||||
case ARCHIVE = '\\Archive';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
/**
|
||||
* Dispatched after a status change (star, read, archive, delete, move) so that
|
||||
* the change is mirrored on the IMAP server asynchronously.
|
||||
*
|
||||
* action values:
|
||||
* 'flag' — set \Flagged (star)
|
||||
* 'unflag' — clear \Flagged (unstar)
|
||||
* 'seen' — set \Seen (mark read)
|
||||
* 'unseen' — clear \Seen (mark unread)
|
||||
* 'archive' — move message to the account's \Archive mailbox
|
||||
* 'trash' — move message to the account's \Trash mailbox
|
||||
* 'delete' — permanently delete (used when message is already in Trash)
|
||||
*
|
||||
* @param int[] $messageIds App Message entity IDs to act on
|
||||
* @param string $action One of the values listed above
|
||||
*/
|
||||
readonly class ApplyImapFlagsMessage
|
||||
{
|
||||
/**
|
||||
* @param int[] $messageIds
|
||||
*/
|
||||
public function __construct(
|
||||
public array $messageIds,
|
||||
public string $action,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Domain\Helper\ImapConnectionFactory;
|
||||
use App\Entity\Account;
|
||||
use App\Entity\Mailbox;
|
||||
use App\Entity\Message;
|
||||
use App\Message\ApplyImapFlagsMessage;
|
||||
use App\Repository\MailboxRepository;
|
||||
use App\Repository\MessageRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class ApplyImapFlagsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageRepository $messageRepository,
|
||||
private readonly MailboxRepository $mailboxRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(ApplyImapFlagsMessage $message): void
|
||||
{
|
||||
$messages = $this->messageRepository->findBy(['id' => $message->messageIds]);
|
||||
|
||||
if (count($messages) === 0) {
|
||||
$this->logger->warning('ApplyImapFlagsHandler: no messages found', [
|
||||
'ids' => $message->messageIds,
|
||||
'action' => $message->action,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Group messages by account so we open one IMAP connection per account,
|
||||
// then sub-group by mailbox within each account.
|
||||
/** @var array<int, array<int, Message[]>> $byAccount accountId → mailboxId → Message[] */
|
||||
$byAccount = [];
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$accountId = $msg->getMailbox()->getAccount()->getId();
|
||||
$mailboxId = $msg->getMailbox()->getId();
|
||||
$byAccount[$accountId][$mailboxId][] = $msg;
|
||||
}
|
||||
|
||||
foreach ($byAccount as $accountId => $byMailbox) {
|
||||
// All messages in the group share the same account object.
|
||||
$firstMessage = array_values($byMailbox)[0][0];
|
||||
$account = $firstMessage->getMailbox()->getAccount();
|
||||
|
||||
try {
|
||||
$client = ImapConnectionFactory::connect($account);
|
||||
$this->processAccount($client, $account, $byMailbox, $message->action);
|
||||
$client->disconnect();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('ApplyImapFlagsHandler: IMAP error', [
|
||||
'accountId' => $accountId,
|
||||
'action' => $message->action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Message[]> $byMailbox mailboxId → Message[]
|
||||
*/
|
||||
private function processAccount(
|
||||
Client $client,
|
||||
Account $account,
|
||||
array $byMailbox,
|
||||
string $action,
|
||||
): void {
|
||||
// Resolve destination mailbox once per account for move actions.
|
||||
$destinationPath = null;
|
||||
|
||||
if ($action === 'archive' || $action === 'trash') {
|
||||
$destinationPath = $this->resolveDestinationPath($client, $account, $action);
|
||||
|
||||
if ($destinationPath === null) {
|
||||
$this->logger->warning('ApplyImapFlagsHandler: destination mailbox not found', [
|
||||
'accountId' => $account->getId(),
|
||||
'action' => $action,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($byMailbox as $mailboxId => $messages) {
|
||||
$mailbox = $this->mailboxRepository->find($mailboxId);
|
||||
|
||||
if ($mailbox === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->processMailbox($client, $mailbox, $messages, $action, $destinationPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('ApplyImapFlagsHandler: mailbox error', [
|
||||
'mailboxId' => $mailboxId,
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Message[] $messages
|
||||
*/
|
||||
private function processMailbox(
|
||||
Client $client,
|
||||
Mailbox $mailbox,
|
||||
array $messages,
|
||||
string $action,
|
||||
?string $destinationPath,
|
||||
): void {
|
||||
$folder = $client->getFolder($mailbox->getName());
|
||||
|
||||
if ($folder === null) {
|
||||
$this->logger->warning('ApplyImapFlagsHandler: folder not found', [
|
||||
'mailbox' => $mailbox->getName(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
// Messages synced from IMAP have an imapUid; locally-composed
|
||||
// messages that were never appended to IMAP do not.
|
||||
if ($msg->getImapUid() === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->applyToMessage($folder, $msg->getImapUid(), $action, $destinationPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('ApplyImapFlagsHandler: per-message error', [
|
||||
'messageId' => $msg->getId(),
|
||||
'uid' => $msg->getImapUid(),
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function applyToMessage(
|
||||
\Webklex\PHPIMAP\Folder $folder,
|
||||
int $uid,
|
||||
string $action,
|
||||
?string $destinationPath,
|
||||
): void {
|
||||
// Fetch the live message from IMAP by UID so we get the Webklex
|
||||
// Message object with its write methods.
|
||||
$imapMessage = $folder->messages()
|
||||
->whereUid($uid)
|
||||
->get()
|
||||
->first();
|
||||
|
||||
if ($imapMessage === null) {
|
||||
$this->logger->warning('ApplyImapFlagsHandler: UID not found on server', [
|
||||
'uid' => $uid,
|
||||
'folder' => $folder->path,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
'flag' => $imapMessage->setFlag('Flagged'),
|
||||
'unflag' => $imapMessage->unsetFlag('Flagged'),
|
||||
'seen' => $imapMessage->setFlag('Seen'),
|
||||
'unseen' => $imapMessage->unsetFlag('Seen'),
|
||||
'archive' => $imapMessage->move($destinationPath),
|
||||
'trash' => $imapMessage->move($destinationPath),
|
||||
'delete' => $imapMessage->delete(expunge: true),
|
||||
default => $this->logger->warning('ApplyImapFlagsHandler: unknown action', ['action' => $action]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the IMAP folder path for the destination mailbox.
|
||||
* For archive: prefers \Archive, falls back to \Sent (unusual but safe).
|
||||
* For trash: uses \Trash.
|
||||
*/
|
||||
private function resolveDestinationPath(Client $client, Account $account, string $action): ?string
|
||||
{
|
||||
$specialUse = ($action === 'archive') ? '\\Archive' : '\\Trash';
|
||||
|
||||
// Look up in our DB first — fastest path.
|
||||
$mailbox = $this->mailboxRepository->findOneBy([
|
||||
'account' => $account,
|
||||
'specialUse' => $specialUse,
|
||||
]);
|
||||
|
||||
if ($mailbox !== null) {
|
||||
return $mailbox->getFullPath();
|
||||
}
|
||||
|
||||
// Fall back to scanning server folders by name.
|
||||
$nameMap = [
|
||||
'\\Trash' => ['Trash', 'Deleted', 'Deleted Items', 'Deleted Messages'],
|
||||
'\\Archive' => ['Archive', 'Archives'],
|
||||
];
|
||||
|
||||
$candidates = $nameMap[$specialUse] ?? [];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
try {
|
||||
$folder = $client->getFolder($candidate);
|
||||
|
||||
if ($folder !== null) {
|
||||
return $folder->path;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Not found — try next.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,10 @@ class MailboxRepository extends ServiceEntityRepository
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
public function findArchiveMailboxForAccount(Account $account): ?Mailbox
|
||||
{
|
||||
return $this->findOneBy(['account' => $account, 'specialUse' => MailboxSpecialUse::ARCHIVE]);
|
||||
}
|
||||
public function findSentMailboxForAccount(Account $account): ?Mailbox
|
||||
{
|
||||
return $this->findOneBy(['account' => $account, 'specialUse' => MailboxSpecialUse::SENT]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% set isThread = item.messages is defined %}
|
||||
|
||||
{% if isThread %}
|
||||
{% set type = 'thread' %}
|
||||
{% set unread = item.unreadCount > 0 %}
|
||||
{% set subject = item.subject ?: ('thread_row.no_subject'|trans) %}
|
||||
{% set starred = item.starredAt is not null %}
|
||||
@@ -15,6 +16,7 @@
|
||||
|
||||
{% set rowId = item.id %}
|
||||
{% else %}
|
||||
{% set type = 'message' %}
|
||||
{% set unread = item.seenAt is null %}
|
||||
{% set subject = item.subject ?: ('thread_row.no_subject'|trans) %}
|
||||
{% set starred = item.starredAt is not null %}
|
||||
@@ -68,6 +70,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->message-row#toggleStar"
|
||||
data-message-row-star-url-param="{{ path('app_status_star', {'type': type, 'id': rowId}) }}"
|
||||
class="z-10 pointer-events-auto cursor-pointer flex-shrink-0 text-gray-300 dark:text-gray-600 hover:text-amber-400 transition-colors {{ starred ? '!text-amber-400' : '' }}"
|
||||
aria-label="{{ starred ? 'thread_row.unstar'|trans : 'thread_row.star'|trans }}"
|
||||
>
|
||||
@@ -123,6 +126,7 @@
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-end invisible group-hover:visible pointer-events-auto gap-1">
|
||||
<button type="button" data-action="click->message-row#archive"
|
||||
data-message-row-archive-url-param="{{ path('app_status_archive', {'type': type, 'id': rowId}) }}"
|
||||
aria-label="{{ 'thread_row.actions.archive'|trans }}"
|
||||
title="{{ 'thread_row.actions.archive'|trans }}"
|
||||
class="z-10 p-1.5 cursor-pointer rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
{# ── flex row for content only ── #}
|
||||
<div class="flex items-center gap-3 px-5 py-3.5 text-base font-medium">
|
||||
{{ message }}
|
||||
<span data-toast-target="countdown"></span>s
|
||||
{% if extra.undo_url is defined %}
|
||||
<span data-toast-target="countdown"></span>s
|
||||
<button
|
||||
data-controller="undo-send"
|
||||
data-undo-send-url-value="{{ extra.undo_url }}"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<turbo-stream action="append" target="toast-region">
|
||||
<template>
|
||||
{% include '_partials/_toast.html.twig' with {
|
||||
message: message|trans
|
||||
} %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
@@ -1,2 +1,2 @@
|
||||
<turbo-stream action="remove" target="thread_{{ thread.id }}">
|
||||
<turbo-stream action="remove" target="thread_{{ message is defined ? message.id : thread.id }}">
|
||||
</turbo-stream>
|
||||
@@ -1,5 +1,5 @@
|
||||
<turbo-stream action="replace" target="thread_{{ thread.id }}">
|
||||
<turbo-stream action="replace" target="thread_{{ message is defined ? message.id : thread.id }}">
|
||||
<template>
|
||||
{% include '_partials/_thread_row.html.twig' with { thread: thread } %}
|
||||
{% include '_partials/_thread_row.html.twig' %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
@@ -120,6 +120,8 @@ form:
|
||||
cancel: Cancel
|
||||
|
||||
toast:
|
||||
error:
|
||||
no_archive_mailbox: There is no archive mailbox.
|
||||
dismiss: Dismiss
|
||||
|
||||
mailbox:
|
||||
|
||||
Reference in new issue
Block a user