mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Settings → Categories: one page for the inbox tabs
What sorts mail into tabs and the tabs themselves were two cards under General. They are their own page now, in the Mail group: the sorting card, then every tab on one list in the order the inbox draws them. - Built-in tabs carry an On/Off switch. Off releases conversations somebody dragged into the category and queues the re-sort the other sorting choices queue; on queues it too, and the mail comes back. - The reader's own tabs keep rename and delete. - Everything but Primary is dragged into place, or moved with buttons from a keyboard or a phone, as on the account list. Primary sits above the list: always first, never off. The order endpoint keeps only keys the reader has, so a request cannot plant a key naming somebody else's category. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c0769c1e6
commit
aa735e88a0
15 files changed
+642
-122
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import Sortable from "sortablejs"
|
||||
import { requestFailed } from "../../request_errors.js"
|
||||
|
||||
/**
|
||||
* The order of the inbox tabs: drag on a pointer, buttons everywhere else.
|
||||
*
|
||||
* The account list's controller (settings/account_order_controller) with tab
|
||||
* keys where it has ids — the same reasons hold: sortablejs directly so the
|
||||
* request can carry its CSRF token, the whole order in every write so a
|
||||
* duplicated request lands on the same arrangement, one write in flight and
|
||||
* only the latest order sent, and the rows put back if the server refuses.
|
||||
*
|
||||
* Primary is not in this list. It always comes first, so the page draws it
|
||||
* above the list rather than as a row that refuses to move.
|
||||
*
|
||||
* Values:
|
||||
* url — InboxCategoryController::order
|
||||
* csrf — csrf_token('settings-categories')
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["list"]
|
||||
static values = { url: String, csrf: String }
|
||||
|
||||
connect() {
|
||||
this.sortable = Sortable.create(this.listTarget, {
|
||||
handle: ".tab-drag-handle",
|
||||
animation: 150,
|
||||
onEnd: () => {
|
||||
this.syncEdges()
|
||||
this.persist()
|
||||
},
|
||||
})
|
||||
|
||||
this.syncEdges()
|
||||
|
||||
this.saved = this.currentKeys()
|
||||
this.inFlight = false
|
||||
this.pending = false
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.sortable?.destroy()
|
||||
}
|
||||
|
||||
/** Keyboard- and touch-reachable equivalent of a drag. */
|
||||
move(event) {
|
||||
const row = event.currentTarget.closest("[data-tab-key]")
|
||||
const direction = event.currentTarget.dataset.direction === "up" ? -1 : 1
|
||||
const rows = this.rows()
|
||||
const index = rows.indexOf(row)
|
||||
const target = index + direction
|
||||
|
||||
if (target < 0 || target >= rows.length) {
|
||||
return
|
||||
}
|
||||
|
||||
direction === -1
|
||||
? rows[target].before(row)
|
||||
: rows[target].after(row)
|
||||
|
||||
this.syncEdges()
|
||||
|
||||
// Focus stays on the control that was pressed, or on its twin when
|
||||
// this move just made it a dead end — see the account list.
|
||||
const pressed = event.currentTarget.dataset.direction
|
||||
const same = row.querySelector(`[data-direction="${pressed}"]`)
|
||||
const other = row.querySelector(`[data-direction="${pressed === "up" ? "down" : "up"}"]`)
|
||||
|
||||
;(same?.disabled ? other : same)?.focus()
|
||||
|
||||
this.persist()
|
||||
}
|
||||
|
||||
syncEdges() {
|
||||
const rows = this.rows()
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const up = row.querySelector('[data-direction="up"]')
|
||||
const down = row.querySelector('[data-direction="down"]')
|
||||
|
||||
if (up) up.disabled = index === 0
|
||||
if (down) down.disabled = index === rows.length - 1
|
||||
})
|
||||
}
|
||||
|
||||
rows() {
|
||||
return [...this.listTarget.querySelectorAll("[data-tab-key]")]
|
||||
}
|
||||
|
||||
currentKeys() {
|
||||
return this.rows().map((row) => row.dataset.tabKey)
|
||||
}
|
||||
|
||||
persist() {
|
||||
if (this.inFlight) {
|
||||
this.pending = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const keys = this.currentKeys()
|
||||
|
||||
this.inFlight = true
|
||||
this.pending = false
|
||||
|
||||
fetch(this.urlValue, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
|
||||
body: JSON.stringify({ keys }),
|
||||
keepalive: true,
|
||||
}).then((response) => {
|
||||
if (requestFailed(response)) {
|
||||
this.revert()
|
||||
} else {
|
||||
this.saved = keys
|
||||
}
|
||||
}).catch(() => {
|
||||
requestFailed(null)
|
||||
this.revert()
|
||||
}).finally(() => {
|
||||
this.inFlight = false
|
||||
|
||||
if (this.pending) {
|
||||
this.persist()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revert() {
|
||||
this.pending = false
|
||||
|
||||
const rows = new Map(this.rows().map((row) => [row.dataset.tabKey, row]))
|
||||
|
||||
this.saved.forEach((key) => {
|
||||
const row = rows.get(key)
|
||||
|
||||
if (row) this.listTarget.append(row)
|
||||
})
|
||||
|
||||
this.syncEdges()
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ final class CategorySortingController extends AbstractController
|
||||
$this->bus->dispatch(new ResortMailboxMessage((int) $user->id));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'general']);
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'categories']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +163,6 @@ final class CategorySortingController extends AbstractController
|
||||
|
||||
$this->bus->dispatch(new ReclassifyRecentMessage((int) $user->id, $limit));
|
||||
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'general']);
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'categories']);
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,28 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Settings;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Domain\Exception\InvalidInboxCategoryException;
|
||||
use App\Entity\Mail\InboxCategory;
|
||||
use App\Entity\User\User;
|
||||
use App\Infrastructure\Messaging\Message\ResortMailboxMessage;
|
||||
use App\Repository\Mail\MessageThreadRepository;
|
||||
use App\Security\Voter\OwnershipVoter;
|
||||
use App\Service\Mail\InboxCategoryManager;
|
||||
use App\Service\Mail\InboxTabs;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
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 Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* The inbox tabs a reader makes themselves — Settings → General.
|
||||
* The inbox tabs — Settings → Categories: the reader's own, which built-in ones
|
||||
* are switched on, and the order of all of them.
|
||||
*
|
||||
* Plain form posts that redirect back to the page, like the sorting card beside
|
||||
* it: three small writes on a short list do not need a frame of their own, and
|
||||
@@ -40,9 +48,103 @@ final class InboxCategoryController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly InboxCategoryManager $manager,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly InboxTabs $tabs,
|
||||
private readonly MessageThreadRepository $threads,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MessageBusInterface $bus,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch one built-in tab on or off.
|
||||
*
|
||||
* Off is not a matter of display: nothing sorts into the category any more
|
||||
* and its mail moves to Primary (CategorySorting::$disabled), so the change
|
||||
* queues the same re-sort the other sorting choices do. On queues it too,
|
||||
* and the mail comes back, because every message's category is recomputed
|
||||
* from what is stored.
|
||||
*
|
||||
* A conversation somebody dragged into the category is released first:
|
||||
* the re-sort skips pinned conversations, and one pinned to a tab that is
|
||||
* not there would stay in no tab for ever — the case InboxCategoryManager
|
||||
* handles for a deleted category of the reader's own.
|
||||
*/
|
||||
#[Route('/switch', name: 'switch', methods: ['POST'])]
|
||||
public function switch(Request $request): Response
|
||||
{
|
||||
$this->assertCsrf($request, self::CSRF);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$category = MessageCategory::tryFrom((string) $request->request->get('key', ''));
|
||||
|
||||
// Primary is refused rather than ignored silently: the page never
|
||||
// offers it, so a request for it is not one the page made.
|
||||
if (null === $category || MessageCategory::Primary === $category) {
|
||||
return $this->back();
|
||||
}
|
||||
|
||||
$on = '1' === (string) $request->request->get('on', '');
|
||||
$off = array_values(array_filter(
|
||||
$user->categorySorting->switchedOff(),
|
||||
static fn (MessageCategory $switched): bool => $switched !== $category,
|
||||
));
|
||||
|
||||
if (false === $on) {
|
||||
$off[] = $category;
|
||||
}
|
||||
|
||||
if ($off === $user->categorySorting->switchedOff()) {
|
||||
return $this->back();
|
||||
}
|
||||
|
||||
if (false === $on) {
|
||||
$this->threads->unpinFromCategory($user, $category);
|
||||
}
|
||||
|
||||
$user->categorySorting->switchOff($off);
|
||||
$this->em->flush();
|
||||
|
||||
// After the flush, so the worker reads the new setting.
|
||||
$this->bus->dispatch(new ResortMailboxMessage((int) $user->id));
|
||||
|
||||
return $this->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* The order of the tabs after Primary, as the whole list of keys.
|
||||
*
|
||||
* Kept to the keys the reader has — a built-in name or one of their own
|
||||
* categories — so a request cannot plant a key that names somebody else's.
|
||||
* A tab the list leaves out keeps its place after the ones it names
|
||||
* (InboxTabs::managedFor()), which makes a partial or duplicated request
|
||||
* harmless rather than destructive.
|
||||
*/
|
||||
#[Route('/order', name: 'order', methods: ['POST'])]
|
||||
public function order(Request $request): JsonResponse
|
||||
{
|
||||
$this->assertCsrf($request, self::CSRF);
|
||||
|
||||
$keys = $request->toArray()['keys'] ?? null;
|
||||
|
||||
if (false === is_array($keys)) {
|
||||
return $this->json(['ok' => false], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$known = array_map(static fn ($tab): string => $tab->value, $this->tabs->managedFor($user));
|
||||
|
||||
$user->categorySorting->arrange(array_values(array_filter(
|
||||
array_map(static fn (mixed $key): string => is_string($key) ? $key : '', $keys),
|
||||
static fn (string $key): bool => true === in_array($key, $known, true),
|
||||
)));
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json(['ok' => true]);
|
||||
}
|
||||
|
||||
#[Route('', name: 'create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
@@ -100,6 +202,6 @@ final class InboxCategoryController extends AbstractController
|
||||
|
||||
private function back(): Response
|
||||
{
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'general']);
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'categories']);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use App\Repository\Calendar\BookingPageRepository;
|
||||
use App\Repository\Calendar\CalendarRepository;
|
||||
use App\Repository\Calendar\CalendarShareLinkRepository;
|
||||
use App\Repository\Mail\AccountRepository;
|
||||
use App\Repository\Mail\InboxCategoryRepository;
|
||||
use App\Service\Mail\InboxTabs;
|
||||
use App\Repository\Push\PushDeliveryRepository;
|
||||
use App\Repository\User\ApiTokenRepository;
|
||||
use App\Repository\User\PushSubscriptionRepository;
|
||||
@@ -63,7 +63,7 @@ final class SettingsController extends AbstractController
|
||||
/** Where /settings opens: the first entry in the navigation. */
|
||||
private const string DEFAULT_SECTION = 'profile';
|
||||
|
||||
private const array SECTIONS = ['health', 'accounts', 'profile', 'security', 'labels', 'calendars', 'sharing', 'filters', 'insights', 'ai', 'integrations', 'appearance', 'aliases', 'read-receipts', 'signature', 'templates', 'app-passwords', 'notifications', 'general', 'reading-pane', 'shortcuts', 'disposal', 'calendar-behaviour'];
|
||||
private const array SECTIONS = ['health', 'accounts', 'profile', 'security', 'labels', 'calendars', 'sharing', 'filters', 'insights', 'ai', 'integrations', 'appearance', 'aliases', 'read-receipts', 'signature', 'templates', 'app-passwords', 'notifications', 'general', 'categories', 'reading-pane', 'shortcuts', 'disposal', 'calendar-behaviour'];
|
||||
|
||||
/**
|
||||
* One icon per AI feature, here rather than on the enum.
|
||||
@@ -88,7 +88,7 @@ final class SettingsController extends AbstractController
|
||||
private readonly CalendarShareLinkRepository $shareLinkRepository,
|
||||
private readonly BookingPageRepository $bookingPageRepository,
|
||||
private readonly MailRuleRepository $mailRuleRepository,
|
||||
private readonly InboxCategoryRepository $inboxCategoryRepository,
|
||||
private readonly InboxTabs $inboxTabs,
|
||||
private readonly PushSubscriptionRegistry $pushSubscriptionRegistry,
|
||||
private readonly PushSubscriptionRepository $pushSubscriptions,
|
||||
private readonly PushDeliveryRepository $pushDeliveries,
|
||||
@@ -157,10 +157,11 @@ final class SettingsController extends AbstractController
|
||||
'labels' => $this->labelRepository->findForUserTreeOrdered($this->getUser()),
|
||||
'calendars' => $this->calendarRepository->findForUser($this->getUser()),
|
||||
'rules' => $this->mailRuleRepository->findForUserOrdered($this->getUser()),
|
||||
// Only the General section draws them, and every other settings page
|
||||
// would otherwise pay a read for a list it never shows.
|
||||
'inboxCategories' => 'general' === $section
|
||||
? $this->inboxCategoryRepository->findForUserOrdered($this->getUser())
|
||||
// Only the Categories section draws them, and every other settings
|
||||
// page would otherwise pay a read for a list it never shows. Every
|
||||
// tab, switched off or not, in the reader's order.
|
||||
'categoryTabs' => 'categories' === $section && $user instanceof User
|
||||
? $this->inboxTabs->managedFor($user)
|
||||
: [],
|
||||
'apiTokens' => $this->apiTokenRepository->findForUser($this->getUser()),
|
||||
'apiTokenForm' => $this->createForm(ApiTokenType::class)->createView(),
|
||||
|
||||
@@ -2528,6 +2528,32 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this reader's conversations pinned to a built-in category, before
|
||||
* that category is switched off.
|
||||
*
|
||||
* The recompute statements skip a pinned conversation, so one dragged into
|
||||
* a category that is then switched off would never be resolved again. Only
|
||||
* conversations shown in that category: one in a tab of the reader's own
|
||||
* keeps its pin, since that tab is still there.
|
||||
*/
|
||||
public function unpinFromCategory(\App\Entity\User\User $user, MessageCategory $category): int
|
||||
{
|
||||
return (int) $this->getEntityManager()->getConnection()->executeStatement(
|
||||
<<<'SQL'
|
||||
UPDATE message_thread t
|
||||
SET category_pinned_at = NULL
|
||||
FROM account a
|
||||
WHERE a.id = t.account_id
|
||||
AND a.usr_id = :user
|
||||
AND t.category = :category
|
||||
AND t.user_category_id IS NULL
|
||||
AND t.category_pinned_at IS NOT NULL
|
||||
SQL,
|
||||
['user' => $user->id, 'category' => $category->value],
|
||||
);
|
||||
}
|
||||
|
||||
public function recomputeCategoriesForAccount(Account $account): int
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{#
|
||||
settings/_inbox_categories.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The inbox tabs this person made, beside the five that come with plMail.
|
||||
|
||||
A category is only a name. What goes into it is decided by a filter's "File in
|
||||
category" action, so the card says so and links there — a tab with nothing
|
||||
filing into it stays out of the strip, and a card that did not explain that
|
||||
would look broken the first time somebody made one.
|
||||
|
||||
Plain posts that redirect back, like the sorting card above: three small writes
|
||||
on a short list.
|
||||
|
||||
Accepts:
|
||||
categories InboxCategory[] in the order their tabs are drawn
|
||||
#}
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-[11px] text-ink-faint">
|
||||
{{ 'settings.categories.hint'|trans }}
|
||||
<a href="{{ path('app_settings_index', { section: 'filters' }) }}" class="text-accent hover:underline">{{ 'settings.categories.filters_link'|trans }}</a>
|
||||
</p>
|
||||
|
||||
{% if categories is empty %}
|
||||
<p class="mt-3 text-xs text-ink-faint" data-inbox-categories-empty>{{ 'settings.categories.empty'|trans }}</p>
|
||||
{% else %}
|
||||
<ul class="mt-3 divide-y divide-line rounded-pane border border-line" data-inbox-categories>
|
||||
{% for category in categories %}
|
||||
<li class="flex items-center gap-2 px-3 py-2">
|
||||
<form method="post" action="{{ path('app_settings_categories_rename', { id: category.id }) }}"
|
||||
data-turbo="false" class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<input type="text" name="name" value="{{ category.name }}" required maxlength="40"
|
||||
aria-label="{{ 'settings.categories.name_label'|trans }}"
|
||||
class="min-w-0 flex-1 h-8 rounded-pane border border-line bg-surface px-2 text-sm text-ink">
|
||||
<button type="submit"
|
||||
class="shrink-0 h-8 px-2.5 rounded-pane border border-line text-xs text-ink-muted hover:text-ink hover:bg-hover cursor-pointer transition-colors">
|
||||
{{ 'settings.categories.rename'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ path('app_settings_categories_delete', { id: category.id }) }}"
|
||||
data-turbo="false"
|
||||
data-turbo-confirm="{{ 'settings.categories.delete_confirm'|trans({ '%name%': category.name }) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<button type="submit"
|
||||
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-ink-faint hover:text-danger hover:bg-hover cursor-pointer transition-colors"
|
||||
aria-label="{{ 'settings.categories.delete'|trans }}"
|
||||
title="{{ 'settings.categories.delete'|trans }}">
|
||||
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ path('app_settings_categories_create') }}" data-turbo="false" class="mt-3 flex items-center gap-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<input type="text" name="name" required maxlength="40"
|
||||
placeholder="{{ 'settings.categories.placeholder'|trans }}"
|
||||
aria-label="{{ 'settings.categories.name_label'|trans }}"
|
||||
class="min-w-0 flex-1 h-8 rounded-pane border border-line bg-surface px-2 text-sm text-ink">
|
||||
<button type="submit"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium bg-accent hover:bg-accent-strong text-accent-ink transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i>
|
||||
{{ 'settings.categories.add'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,149 @@
|
||||
{#
|
||||
settings/_inbox_tabs.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Every inbox tab on one list, in the order the inbox draws them: the built-in
|
||||
ones with a switch, the reader's own with rename and delete, and all of them
|
||||
but Primary in an order the reader drags into place.
|
||||
|
||||
ONE LIST, NOT TWO. The strip is one row of tabs whichever kind each one is, so
|
||||
arranging it is one list too — a reader's "Shipping" belongs between Updates
|
||||
and Promotions if that is where they look for it.
|
||||
|
||||
PRIMARY IS NOT IN THE SORTABLE LIST. It is always first and cannot be switched
|
||||
off: it is the tab the inbox opens on and where a switched-off tab's mail goes.
|
||||
A row that looked like the others but refused to move would be a puzzle; a row
|
||||
above the list that says why it stays is not.
|
||||
|
||||
A switch posts on change, like every other switch on these pages, and
|
||||
re-sorts the mailbox (InboxCategoryController::switch). The order is written as
|
||||
one JSON list after every move (settings--tab-order). Rename, delete and add
|
||||
are plain posts that come back to this page.
|
||||
|
||||
What fills a tab of the reader's own is a filter's "File in category" action,
|
||||
so the card says so and links there: a tab with nothing filing into it stays
|
||||
out of the strip, and a card that did not explain that would look broken the
|
||||
first time somebody made one.
|
||||
|
||||
Accepts:
|
||||
tabs InboxTab[] every tab, switched off or not, in order
|
||||
switchedOff MessageCategory[] the built-ins switched off
|
||||
#}
|
||||
{% set segText = 'flex h-full items-center justify-center px-2.5 py-1 text-center text-[11px] leading-tight text-ink-muted
|
||||
peer-checked:bg-accent peer-checked:text-accent-ink
|
||||
peer-focus-visible:ring-2 peer-focus-visible:ring-inset peer-focus-visible:ring-accent
|
||||
transition-colors cursor-pointer' %}
|
||||
{% set moveButton = 'w-5 h-4 flex items-center justify-center rounded text-ink-faint hover:text-ink hover:bg-hover transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed' %}
|
||||
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-[11px] text-ink-faint">
|
||||
{{ 'settings.categories.hint'|trans }}
|
||||
<a href="{{ path('app_settings_index', { section: 'filters' }) }}" class="text-accent hover:underline">{{ 'settings.categories.filters_link'|trans }}</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-3 rounded-pane border border-line">
|
||||
{% set primary = tabs|first %}
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 border-b border-line" data-tab-fixed="{{ primary.value }}">
|
||||
<span class="w-5 shrink-0 flex justify-center text-ink-faint" aria-hidden="true">
|
||||
<i class="fa-solid fa-thumbtack text-[10px]"></i>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink">{{ ('tabs.' ~ primary.value)|trans }}</p>
|
||||
<p class="text-[11px] text-ink-faint">{{ 'settings.categories.primary_fixed'|trans }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-line"
|
||||
data-controller="settings--tab-order"
|
||||
data-settings--tab-order-url-value="{{ path('app_settings_categories_order') }}"
|
||||
data-settings--tab-order-csrf-value="{{ csrf_token('settings-categories') }}"
|
||||
data-settings--tab-order-target="list">
|
||||
{% for tab in tabs|slice(1) %}
|
||||
{% set label = tab.builtIn ? ('tabs.' ~ tab.value)|trans : tab.name %}
|
||||
{% set isOff = tab.builtIn and tab.builtIn in switchedOff %}
|
||||
<li class="flex items-center gap-3 px-3 py-2.5" data-tab-key="{{ tab.value }}">
|
||||
{# Buttons as well as a handle, as on the account list: a drag
|
||||
is unreachable from a keyboard and fiddly on a phone. #}
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<button type="button" data-action="settings--tab-order#move" data-direction="up"
|
||||
class="{{ moveButton }}" {{ loop.first ? 'disabled' : '' }}
|
||||
aria-label="{{ 'settings.categories.move_up'|trans({ '%tab%': label }) }}">
|
||||
<i class="fa-solid fa-chevron-up text-[9px]" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="tab-drag-handle w-5 h-4 flex items-center justify-center text-ink-faint hover:text-ink-muted cursor-grab active:cursor-grabbing touch-none"
|
||||
title="{{ 'settings.categories.reorder'|trans }}" aria-hidden="true">
|
||||
<i class="fa-solid fa-grip-vertical text-[10px]"></i>
|
||||
</span>
|
||||
<button type="button" data-action="settings--tab-order#move" data-direction="down"
|
||||
class="{{ moveButton }}" {{ loop.last ? 'disabled' : '' }}
|
||||
aria-label="{{ 'settings.categories.move_down'|trans({ '%tab%': label }) }}">
|
||||
<i class="fa-solid fa-chevron-down text-[9px]" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if tab.builtIn %}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium {{ isOff ? 'text-ink-faint' : 'text-ink' }}">{{ label }}</p>
|
||||
<p class="text-[11px] text-ink-faint">
|
||||
{{ (isOff ? 'settings.categories.built_in_off' : 'settings.categories.built_in')|trans }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ path('app_settings_categories_switch') }}" data-turbo="false" data-controller="ui--auto-submit" class="shrink-0">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<input type="hidden" name="key" value="{{ tab.value }}">
|
||||
<div class="grid grid-flow-col auto-cols-fr rounded-pane border border-line overflow-hidden"
|
||||
role="radiogroup" aria-label="{{ label }}">
|
||||
{% for on in [true, false] %}
|
||||
<label>
|
||||
<input type="radio" name="on" class="peer sr-only"
|
||||
value="{{ on ? '1' : '0' }}"
|
||||
data-action="change->ui--auto-submit#submit"
|
||||
{{ isOff != on ? 'checked' : '' }}>
|
||||
<span class="{{ segText }}">{{ (on ? 'settings.categories.on' : 'settings.categories.off')|trans }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('app_settings_categories_rename', { id: tab.categoryId }) }}"
|
||||
data-turbo="false" class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<input type="text" name="name" value="{{ tab.name }}" required maxlength="40"
|
||||
aria-label="{{ 'settings.categories.name_label'|trans }}"
|
||||
class="min-w-0 flex-1 h-8 rounded-pane border border-line bg-surface px-2 text-sm text-ink">
|
||||
<button type="submit"
|
||||
class="shrink-0 h-8 px-2.5 rounded-pane border border-line text-xs text-ink-muted hover:text-ink hover:bg-hover cursor-pointer transition-colors">
|
||||
{{ 'settings.categories.rename'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ path('app_settings_categories_delete', { id: tab.categoryId }) }}"
|
||||
data-turbo="false" class="shrink-0"
|
||||
data-turbo-confirm="{{ 'settings.categories.delete_confirm'|trans({ '%name%': tab.name }) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<button type="submit"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-ink-faint hover:text-danger hover:bg-hover cursor-pointer transition-colors"
|
||||
aria-label="{{ 'settings.categories.delete'|trans }}"
|
||||
title="{{ 'settings.categories.delete'|trans }}">
|
||||
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ path('app_settings_categories_create') }}" data-turbo="false" class="mt-3 flex items-center gap-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
|
||||
<input type="text" name="name" required maxlength="40"
|
||||
placeholder="{{ 'settings.categories.placeholder'|trans }}"
|
||||
aria-label="{{ 'settings.categories.name_label'|trans }}"
|
||||
class="min-w-0 flex-1 h-8 rounded-pane border border-line bg-surface px-2 text-sm text-ink">
|
||||
<button type="submit"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium bg-accent hover:bg-accent-strong text-accent-ink transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i>
|
||||
{{ 'settings.categories.add'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -152,6 +152,7 @@
|
||||
{ section: 'read-receipts', icon: 'fa-solid fa-envelope-open-text', label: 'settings.nav.read_receipts' },
|
||||
{ section: 'signature', icon: 'fa-solid fa-signature' },
|
||||
{ section: 'templates', icon: 'fa-regular fa-file-lines' },
|
||||
{ section: 'categories', icon: 'fa-solid fa-inbox' },
|
||||
{ section: 'labels', icon: 'fa-solid fa-tag' },
|
||||
{ section: 'filters', icon: 'fa-solid fa-filter' },
|
||||
{ section: 'disposal', icon: 'fa-solid fa-trash-can' },
|
||||
@@ -398,6 +399,14 @@
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
</div>
|
||||
{% elseif section == 'categories' %}
|
||||
{# Everything about the inbox tabs on one page: what sorts
|
||||
mail into them, then the tabs themselves — which built-in
|
||||
ones are on, the reader's own, and the order of all of
|
||||
them. The sorting card used to sit under General, and the
|
||||
tabs it sorts into were a second card beside it. #}
|
||||
<div class="space-y-4">
|
||||
{% embed 'settings/_card.html.twig' with {
|
||||
heading: 'settings.sorting.heading'|trans,
|
||||
subheading: 'settings.sorting.subheading'|trans,
|
||||
@@ -412,15 +421,15 @@
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{# The tabs this person names themselves — what sorting
|
||||
above cannot give them. Filled by a filter's "File in
|
||||
category" action, which the card says. #}
|
||||
{% embed 'settings/_card.html.twig' with {
|
||||
heading: 'settings.categories.heading'|trans,
|
||||
subheading: 'settings.categories.subheading'|trans,
|
||||
} %}
|
||||
{% block body %}
|
||||
{% include 'settings/_inbox_categories.html.twig' with { categories: inboxCategories } %}
|
||||
{% include 'settings/_inbox_tabs.html.twig' with {
|
||||
tabs: categoryTabs,
|
||||
switchedOff: app.user.categorySorting.switchedOff(),
|
||||
} %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ final class CategorySortingTest extends WebTestCase
|
||||
$this->em->persist($settings);
|
||||
$this->em->flush();
|
||||
|
||||
$warning = fn (): int => $client->request('GET', '/settings?section=general')
|
||||
$warning = fn (): int => $client->request('GET', '/settings?section=categories')
|
||||
->filter('[data-sorting-tabs-warning]')
|
||||
->count();
|
||||
|
||||
@@ -302,7 +302,7 @@ final class CategorySortingTest extends WebTestCase
|
||||
|
||||
private function token(KernelBrowser $client): string
|
||||
{
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
|
||||
return (string) $crawler
|
||||
->filter('form[action="/settings/sorting"] input[name="_token"]')
|
||||
|
||||
@@ -35,27 +35,138 @@ final class InboxCategoryControllerTest extends WebTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheCardIsOnTheGeneralPageAndListsTheReadersCategories(): void
|
||||
/**
|
||||
* One list, in the reader's order: Primary fixed above it, then the rest
|
||||
* — built-ins and their own mixed — with a switched-off built-in still
|
||||
* listed, so it can be switched back on.
|
||||
*/
|
||||
public function testThePageListsEveryTabInTheReadersOrder(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->category($this->user, 'Shipping');
|
||||
$client = $this->signIn();
|
||||
$shipping = $this->category($this->user, 'Shipping');
|
||||
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$this->user->categorySorting->arrange(['updates', $shipping->tabKey()]);
|
||||
$this->user->categorySorting->switchOff([\App\Domain\Enum\Mail\MessageCategory::Forums]);
|
||||
$this->em->flush();
|
||||
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('Your own tabs', $crawler->text());
|
||||
self::assertSame('Shipping', $crawler->filter('[data-inbox-categories] input[name="name"]')->first()->attr('value'));
|
||||
self::assertSame('primary', $crawler->filter('[data-tab-fixed]')->attr('data-tab-fixed'));
|
||||
self::assertSame(
|
||||
['updates', $shipping->tabKey(), 'social', 'promotions', 'forums'],
|
||||
$crawler->filter('[data-tab-key]')->each(static fn ($row): string => (string) $row->attr('data-tab-key')),
|
||||
);
|
||||
self::assertSame('Shipping', $crawler->filter('[data-tab-key="' . $shipping->tabKey() . '"] input[name="name"]')->attr('value'));
|
||||
self::assertNotNull(
|
||||
$crawler->filter('[data-tab-key="forums"] input[name="on"][value="0"]')->attr('checked'),
|
||||
'the switched-off tab is listed, shown as off',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Off releases the conversations dragged into the category — the re-sort
|
||||
* skips pinned ones, and one pinned to a tab that is not there would be in
|
||||
* no tab for ever — and queues the re-sort. A conversation pinned to a
|
||||
* different built-in keeps its pin.
|
||||
*/
|
||||
public function testSwitchingABuiltInOffReleasesItsPinsAndResorts(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$account = new \App\Entity\Mail\Account();
|
||||
$account->usr = $this->user;
|
||||
$account->name = 'Mail';
|
||||
$account->email = 'switch-' . uniqid('', true) . '@example.test';
|
||||
$account->username = $account->email;
|
||||
$account->authType = \App\Domain\Enum\Account\AuthType::Password->value;
|
||||
$account->imapHost = 'imap.example.test';
|
||||
$account->isActive = true;
|
||||
$this->em->persist($account);
|
||||
|
||||
$forum = $this->pinnedThread($account, \App\Domain\Enum\Mail\MessageCategory::Forums);
|
||||
$updates = $this->pinnedThread($account, \App\Domain\Enum\Mail\MessageCategory::Updates);
|
||||
$this->em->flush();
|
||||
|
||||
$client->request('POST', '/settings/categories/switch', [
|
||||
'_token' => $this->token($client),
|
||||
'key' => 'forums',
|
||||
'on' => '0',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/settings?section=categories');
|
||||
|
||||
$this->em->clear();
|
||||
$user = $this->em->find(User::class, $this->user->id);
|
||||
self::assertSame(['forums'], $user->categorySorting->disabled);
|
||||
|
||||
$pins = $this->connection->fetchAllKeyValue(
|
||||
'SELECT id, category_pinned_at IS NOT NULL FROM message_thread WHERE id IN (:a, :b)',
|
||||
['a' => $forum->id, 'b' => $updates->id],
|
||||
);
|
||||
self::assertFalse((bool) $pins[$forum->id], 'a conversation pinned to the switched-off tab kept its pin');
|
||||
self::assertTrue((bool) $pins[$updates->id], 'a pin to another tab was released too');
|
||||
|
||||
/** @var \Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport $transport */
|
||||
$transport = static::getContainer()->get('messenger.transport.maintenance');
|
||||
$sent = $transport->getSent();
|
||||
self::assertCount(1, array_filter(
|
||||
$sent,
|
||||
static fn ($envelope): bool => $envelope->getMessage() instanceof \App\Infrastructure\Messaging\Message\ResortMailboxMessage,
|
||||
));
|
||||
}
|
||||
|
||||
/** Primary is where a switched-off tab's mail goes, so it cannot be one. */
|
||||
public function testPrimaryCannotBeSwitchedOff(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$client->request('POST', '/settings/categories/switch', [
|
||||
'_token' => $this->token($client),
|
||||
'key' => 'primary',
|
||||
'on' => '0',
|
||||
]);
|
||||
|
||||
$this->em->clear();
|
||||
self::assertSame([], $this->em->find(User::class, $this->user->id)->categorySorting->disabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* The order keeps only keys the reader has: a key for somebody else's
|
||||
* category, a made-up one and Primary are dropped, the rest kept in the
|
||||
* order sent.
|
||||
*/
|
||||
public function testTheOrderKeepsOnlyTheReadersOwnKeys(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$mine = $this->category($this->user, 'Shipping');
|
||||
$stranger = $this->category($this->seedUser('stranger'), 'Theirs');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/settings/categories/order',
|
||||
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $this->token($client)],
|
||||
content: (string) json_encode(['keys' => ['forums', $stranger->tabKey(), 'quatsch', $mine->tabKey(), 'primary', 'social']]),
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$this->em->clear();
|
||||
self::assertSame(
|
||||
['forums', $mine->tabKey(), 'social'],
|
||||
$this->em->find(User::class, $this->user->id)->categorySorting->tabOrder,
|
||||
);
|
||||
}
|
||||
|
||||
public function testACategoryIsCreatedThroughTheRealForm(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
|
||||
$form = $crawler->filter('form[action$="/settings/categories"]')->form(['name' => ' Shipping ']);
|
||||
$client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/settings?section=general');
|
||||
self::assertResponseRedirects('/settings?section=categories');
|
||||
|
||||
$rows = $this->connection->fetchFirstColumn('SELECT name FROM inbox_category WHERE usr_id = :id', ['id' => $this->user->id]);
|
||||
self::assertSame(['Shipping'], $rows);
|
||||
@@ -70,10 +181,10 @@ final class InboxCategoryControllerTest extends WebTestCase
|
||||
$client = $this->signIn();
|
||||
$this->category($this->user, 'Shipping');
|
||||
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
$client->submit($crawler->filter('form[action$="/settings/categories"]')->form(['name' => 'shipping']));
|
||||
|
||||
self::assertResponseRedirects('/settings?section=general');
|
||||
self::assertResponseRedirects('/settings?section=categories');
|
||||
$client->followRedirect();
|
||||
|
||||
self::assertStringContainsString('You already have a category with that name', (string) $client->getResponse()->getContent());
|
||||
@@ -88,13 +199,13 @@ final class InboxCategoryControllerTest extends WebTestCase
|
||||
$client = $this->signIn();
|
||||
$shipping = $this->category($this->user, 'Shipping');
|
||||
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
$client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/rename"]')->form(['name' => 'Parcels']));
|
||||
|
||||
self::assertResponseRedirects();
|
||||
self::assertSame('Parcels', $this->connection->fetchOne('SELECT name FROM inbox_category WHERE id = :id', ['id' => $shipping->id]));
|
||||
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
$client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/delete"]')->form());
|
||||
|
||||
self::assertResponseRedirects();
|
||||
@@ -124,7 +235,7 @@ final class InboxCategoryControllerTest extends WebTestCase
|
||||
public function testAWriteWithoutTheTokenIsRefused(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$client->request('GET', '/settings?section=general');
|
||||
$client->request('GET', '/settings?section=categories');
|
||||
|
||||
$client->request('POST', '/settings/categories', ['name' => 'Sneaky']);
|
||||
|
||||
@@ -162,9 +273,25 @@ final class InboxCategoryControllerTest extends WebTestCase
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function pinnedThread(\App\Entity\Mail\Account $account, \App\Domain\Enum\Mail\MessageCategory $category): \App\Entity\Mail\MessageThread
|
||||
{
|
||||
$thread = new \App\Entity\Mail\MessageThread();
|
||||
$thread->account = $account;
|
||||
$thread->subject = $category->value;
|
||||
$thread->normalizedSubject = $category->value;
|
||||
$thread->threadingMethod = \App\Domain\Enum\Mail\ThreadingMethod::SubjectFallback;
|
||||
$thread->lastMessageAt = new \DateTimeImmutable('-1 hour');
|
||||
$thread->unreadCount = 0;
|
||||
$thread->category = $category;
|
||||
$thread->categoryPinnedAt = new \DateTimeImmutable('-1 hour');
|
||||
$this->em->persist($thread);
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
private function token(KernelBrowser $client): string
|
||||
{
|
||||
$crawler = $client->request('GET', '/settings?section=general');
|
||||
$crawler = $client->request('GET', '/settings?section=categories');
|
||||
|
||||
return (string) $crawler->filter('input[name="_token"][value]')->last()->attr('value');
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ajaxPost, seed } from "./support/config";
|
||||
const STRIP = 'nav a[href*="tab="]';
|
||||
|
||||
async function setTabs(page: Page, option: "Tabs" | "One list"): Promise<void> {
|
||||
await page.goto("/settings?section=general");
|
||||
await page.goto("/settings?section=categories");
|
||||
|
||||
const group = page.getByRole("radiogroup", { name: "Inbox tabs" });
|
||||
|
||||
@@ -22,7 +22,7 @@ async function setTabs(page: Page, option: "Tabs" | "One list"): Promise<void> {
|
||||
// The control submits on change and comes back to this page.
|
||||
if (false === (await group.getByRole("radio", { name: option, exact: true }).isChecked())) {
|
||||
await Promise.all([
|
||||
page.waitForURL(/section=general/),
|
||||
page.waitForURL(/section=categories/),
|
||||
group.getByText(option, { exact: true }).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3778,6 +3778,7 @@ settings:
|
||||
appearance: Darstellung
|
||||
calendar_behaviour: Verhalten
|
||||
calendars: Kalender
|
||||
categories: "Kategorien"
|
||||
disposal: Entsorgen
|
||||
filters: Filter
|
||||
general: Allgemein
|
||||
@@ -3988,31 +3989,38 @@ settings:
|
||||
subheading: 'Was am Ende der Nachrichten steht, die du schreibst.'
|
||||
categories:
|
||||
add: Hinzufügen
|
||||
built_in: "Eingebaut"
|
||||
built_in_off: "Eingebaut · ausgeschaltet — die Mail liegt in Allgemein"
|
||||
created: 'Kategorie „%name%“ angelegt.'
|
||||
delete: Kategorie löschen
|
||||
delete_confirm: 'Kategorie „%name%“ löschen? Die dort abgelegte Mail kommt zurück in den Tab, in dem sie sonst wäre. Es wird keine Mail gelöscht.'
|
||||
deleted: 'Kategorie „%name%“ gelöscht.'
|
||||
empty: 'Du hast noch keine eigenen Tabs.'
|
||||
error:
|
||||
empty: 'Eine Kategorie braucht einen Namen.'
|
||||
reserved: 'Dieser Name gehört zu einem der eingebauten Tabs.'
|
||||
taken: 'Du hast bereits eine Kategorie mit diesem Namen.'
|
||||
too_long: 'Dieser Name ist zu lang (höchstens 40 Zeichen).'
|
||||
filters_link: 'Einstellungen → Filter.'
|
||||
heading: Deine eigenen Tabs
|
||||
hint: 'Ein Tab erscheint im Posteingang, solange er Mail enthält. Mail kommt über einen Filter mit der Aktion „In Kategorie ablegen“ hinein — richte einen ein unter'
|
||||
heading: "Posteingangs-Tabs"
|
||||
hint: "Ein eigener Tab erscheint im Posteingang, solange er Mail enthält. Mail kommt über einen Filter mit der Aktion „In Kategorie ablegen“ hinein — richte einen ein unter"
|
||||
move_down: "%tab% nach unten"
|
||||
move_up: "%tab% nach oben"
|
||||
name_label: Name
|
||||
off: "Aus"
|
||||
on: "An"
|
||||
placeholder: 'z. B. Sendungen'
|
||||
primary_fixed: "Immer zuerst und immer an — Mail aus einem Tab, den du ausschaltest, landet hier."
|
||||
rename: Umbenennen
|
||||
renamed: 'Umbenannt in „%name%“.'
|
||||
subheading: 'Posteingangs-Tabs, die du selbst benennst, neben den fünf, die plMail mitbringt.'
|
||||
reorder: "Zum Umsortieren ziehen"
|
||||
subheading: "Schalte die eingebauten Tabs ein oder aus, leg eigene an und zieh sie in die Reihenfolge, die du willst."
|
||||
sorting:
|
||||
again:
|
||||
button: Nochmal fragen
|
||||
hint: Der Assistent antwortet einmal pro Nachricht und behält die Antwort. Frag noch einmal, wenn sich Modell, Anweisung oder eine plMail-Version geändert haben — es kostet einen Modell-Aufruf pro Nachricht und gilt deshalb nur für deine neueste Mail.
|
||||
label: Neueste Mail neu einsortieren
|
||||
applies_to_new: Wenn du hier etwas änderst, wird deine vorhandene Mail im Hintergrund neu einsortiert — bei einem großen Postfach dauert das ein paar Minuten, gib den Tabs also einen Moment. Gespräche, die du selbst in einen Tab geschoben hast, bleiben, wo sie sind.
|
||||
heading: Kategorien
|
||||
heading: "Sortierung"
|
||||
override:
|
||||
hint: Ein Gmail-Konto kommt bereits von Google sortiert an. Hier entscheidest du, ob plMail das so lässt oder selbst antwortet.
|
||||
label: Bei Gmail-Konten
|
||||
|
||||
@@ -3765,6 +3765,7 @@ settings:
|
||||
appearance: Appearance
|
||||
calendar_behaviour: Behaviour
|
||||
calendars: Calendars
|
||||
categories: "Categories"
|
||||
disposal: Disposal
|
||||
filters: Filters
|
||||
general: General
|
||||
@@ -3969,31 +3970,38 @@ settings:
|
||||
subheading: 'What is added to the bottom of the messages you write.'
|
||||
categories:
|
||||
add: Add
|
||||
built_in: "Built in"
|
||||
built_in_off: "Built in · switched off — its mail is in Primary"
|
||||
created: 'Created "%name%".'
|
||||
delete: Delete category
|
||||
delete_confirm: 'Delete "%name%"? Mail filed there goes back to the tab it would otherwise be in. No mail is deleted.'
|
||||
deleted: 'Deleted "%name%".'
|
||||
empty: 'You have no tabs of your own yet.'
|
||||
error:
|
||||
empty: 'A category needs a name.'
|
||||
reserved: 'That name belongs to one of the built-in tabs.'
|
||||
taken: 'You already have a category with that name.'
|
||||
too_long: 'That name is too long (40 characters at most).'
|
||||
filters_link: 'Settings → Filters.'
|
||||
heading: Your own tabs
|
||||
hint: 'A tab shows in the inbox while it holds mail. Mail goes into it through a filter whose action is "File in category" — set one up under'
|
||||
heading: "Inbox tabs"
|
||||
hint: "A tab of your own shows in the inbox while it holds mail. Mail goes into it through a filter whose action is \"File in category\" — set one up under"
|
||||
move_down: "Move %tab% down"
|
||||
move_up: "Move %tab% up"
|
||||
name_label: Name
|
||||
off: "Off"
|
||||
on: "On"
|
||||
placeholder: 'e.g. Shipping'
|
||||
primary_fixed: "Always first and always on — mail from a tab you switch off lands here."
|
||||
rename: Rename
|
||||
renamed: 'Renamed to "%name%".'
|
||||
subheading: 'Inbox tabs you name yourself, beside the five that come with plMail.'
|
||||
reorder: "Drag to reorder"
|
||||
subheading: "Switch the built-in tabs on or off, add your own, and drag them into the order you want."
|
||||
sorting:
|
||||
again:
|
||||
button: Ask again
|
||||
hint: The assistant answers once per message and keeps the answer. Ask again after the model, the prompt or a plMail release has changed — it costs a model call per message, so it is bounded to your newest mail.
|
||||
label: Sort recent mail again
|
||||
applies_to_new: Changing either of these re-files the mail you already have, in the background — a large mailbox takes a few minutes, so give the tabs a moment to settle. Conversations you moved to a tab yourself are left alone.
|
||||
heading: Categories
|
||||
heading: "Sorting"
|
||||
override:
|
||||
hint: A Gmail account arrives already sorted by Google. This decides whether plMail leaves that alone or answers for itself.
|
||||
label: On Gmail accounts
|
||||
|
||||
@@ -3578,6 +3578,7 @@ settings:
|
||||
appearance: Look o' the ship
|
||||
calendar_behaviour: Behaviour
|
||||
calendars: Almanacs
|
||||
categories: "Holds"
|
||||
disposal: Disposal
|
||||
filters: Sieves
|
||||
general: General
|
||||
@@ -3771,31 +3772,38 @@ settings:
|
||||
subheading: 'What be carved at the foot o'' the scrolls ye write.'
|
||||
categories:
|
||||
add: Add
|
||||
built_in: "Ship's own"
|
||||
built_in_off: "Ship's own · struck — its loot be on the Main deck"
|
||||
created: 'Built the hold "%name%".'
|
||||
delete: Scuttle the hold
|
||||
delete_confirm: 'Scuttle "%name%"? Post stowed there goes back to the hold it would be in otherwise. No post goes over the side.'
|
||||
deleted: 'Scuttled "%name%".'
|
||||
empty: 'Ye have no holds o'' yer own yet.'
|
||||
error:
|
||||
empty: 'A hold needs a name.'
|
||||
reserved: 'That name belongs to one o'' the holds the ship came with.'
|
||||
taken: 'Ye already have a hold by that name.'
|
||||
too_long: 'That name be too long (forty letters at most).'
|
||||
filters_link: 'Settings → Sieves.'
|
||||
heading: Yer own holds
|
||||
hint: 'A hold shows in the inbox while it has post in it. Post goes in through a sieve whose order be "Stow in a hold o'' its own" — make one under'
|
||||
heading: "Tabs o' the inbox"
|
||||
hint: "A tab o' yer own shows in the inbox while it holds letters. Letters go into it through a filter whose order be \"File in category\" — rig one up under"
|
||||
move_down: "Haul %tab% down"
|
||||
move_up: "Haul %tab% up"
|
||||
name_label: Name
|
||||
off: "Nay"
|
||||
on: "Aye"
|
||||
placeholder: 'e.g. Parcels'
|
||||
primary_fixed: "Always first an' always flyin' — loot from a tab ye strike lands here."
|
||||
rename: Rename
|
||||
renamed: 'Renamed to "%name%".'
|
||||
subheading: 'Holds ye name yerself, beside the five the ship came with.'
|
||||
reorder: "Drag t' reorder"
|
||||
subheading: "Raise or strike the ship's own tabs, add yer own, an' haul 'em into whatever order ye fancy."
|
||||
sorting:
|
||||
again:
|
||||
button: "Ask again"
|
||||
hint: "Th' oracle answers once per message an' keeps th' answer. Ask again when th' oracle, its orders or a plMail release have changed — it costs an askin' per message, so it be bounded to yer newest post."
|
||||
label: "Stow recent post again"
|
||||
applies_to_new: "Changin' either o' these re-stows the post ye already have, below decks — a big hold takes a few minutes, so give the tabs a moment. Yarns ye stowed by hand yerself be left be."
|
||||
heading: Categories
|
||||
heading: "Sortin'"
|
||||
override:
|
||||
hint: "A Gmail account arrives already stowed by Google. This decides whether plMail leaves it be or answers for itself."
|
||||
label: "On Gmail accounts"
|
||||
|
||||
@@ -2899,24 +2899,31 @@ settings:
|
||||
system: 'système'
|
||||
categories:
|
||||
add: "Ajouter"
|
||||
built_in: "Intégré"
|
||||
built_in_off: "Intégré · désactivé — son courrier est dans Principal"
|
||||
created: "Catégorie « %name% » créée."
|
||||
delete: "Supprimer la catégorie"
|
||||
delete_confirm: "Supprimer « %name% » ? Le courrier qui y est classé retourne dans l’onglet où il serait sinon. Aucun courrier n’est supprimé."
|
||||
deleted: "Catégorie « %name% » supprimée."
|
||||
empty: "Vous n’avez pas encore d’onglet personnel."
|
||||
error:
|
||||
empty: "Une catégorie a besoin d’un nom."
|
||||
reserved: "Ce nom appartient à l’un des onglets intégrés."
|
||||
taken: "Vous avez déjà une catégorie de ce nom."
|
||||
too_long: "Ce nom est trop long (40 caractères au maximum)."
|
||||
filters_link: "Réglages → Filtres."
|
||||
heading: "Vos propres onglets"
|
||||
hint: "Un onglet apparaît dans la boîte de réception tant qu’il contient du courrier. Le courrier y arrive par un filtre dont l’action est « Classer dans une catégorie » — configurez-en un dans"
|
||||
heading: "Onglets de la boîte de réception"
|
||||
hint: "Un onglet personnel apparaît dans la boîte de réception tant qu’il contient du courrier. Le courrier y arrive par un filtre dont l’action est « Classer dans une catégorie » — configurez-en un dans"
|
||||
move_down: "Descendre %tab%"
|
||||
move_up: "Monter %tab%"
|
||||
name_label: "Nom"
|
||||
off: "Désactivé"
|
||||
on: "Activé"
|
||||
placeholder: "p. ex. Livraisons"
|
||||
primary_fixed: "Toujours en premier et toujours actif — le courrier d’un onglet que vous désactivez arrive ici."
|
||||
rename: "Renommer"
|
||||
renamed: "Renommée en « %name% »."
|
||||
subheading: "Des onglets de boîte de réception que vous nommez vous-même, à côté des cinq fournis avec plMail."
|
||||
reorder: "Faire glisser pour réordonner"
|
||||
subheading: "Activez ou désactivez les onglets intégrés, ajoutez les vôtres et faites-les glisser dans l’ordre que vous voulez."
|
||||
clock:
|
||||
heading: Horloge
|
||||
subheading: Si les heures s’écrivent au format 12 ou 24 heures, et où l’heure actuelle s’affiche. Rien n’est stocké différemment — seuls les chiffres changent.
|
||||
@@ -3806,6 +3813,7 @@ settings:
|
||||
appearance: Apparence
|
||||
calendar_behaviour: Comportement
|
||||
calendars: Agendas
|
||||
categories: "Catégories"
|
||||
disposal: Suppression
|
||||
filters: Filtres
|
||||
general: Général
|
||||
@@ -4017,7 +4025,7 @@ settings:
|
||||
hint: L’assistant répond une fois par message et garde la réponse. Redemandez après un changement de modèle, d’instruction ou de version de plMail — cela coûte un appel au modèle par message, c’est donc limité à votre courrier le plus récent.
|
||||
label: Trier à nouveau le courrier récent
|
||||
applies_to_new: Changer l’un de ces réglages reclasse en arrière-plan le courrier que vous avez déjà — une grosse boîte prend quelques minutes, laissez donc un moment aux onglets pour se stabiliser. Les conversations que vous avez vous-même déplacées dans un onglet ne sont pas touchées.
|
||||
heading: Catégories
|
||||
heading: "Tri"
|
||||
override:
|
||||
hint: Un compte Gmail arrive déjà trié par Google. Ceci décide si plMail laisse ce tri tel quel ou répond lui-même.
|
||||
label: Sur les comptes Gmail
|
||||
|
||||
Reference in new issue
Block a user