Merge pull request 'Fixes and Turkish from public ihasmail (#29–#36)' (#41) from sync/ihasmail-2026-10-05 into main
github/ci (branch) GitHub Actions
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (tag inbuxa-v2026.10.5-g3434a5e) GitHub Actions
ci / github (push) Successful in 3m44s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
announce / announce (release) Successful in 25s
ci / announce (push) Successful in 28s
github/ci (branch) GitHub Actions
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (tag inbuxa-v2026.10.5-g3434a5e) GitHub Actions
ci / github (push) Successful in 3m44s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
announce / announce (release) Successful in 25s
ci / announce (push) Successful in 28s
This commit was merged in pull request #41.
This commit is contained in:
commit
3434a5ed39
25 files changed
+3197
-34
No files matched your search
@@ -35,7 +35,7 @@ settings included, lives on the server, so the container is disposable.
|
|||||||
Everything else is in INBUXA Admin.
|
Everything else is in INBUXA Admin.
|
||||||
- **Sign-in on the mail server's own page**, two-factor included. The webmail
|
- **Sign-in on the mail server's own page**, two-factor included. The webmail
|
||||||
never handles a password to sign someone in, and holds only sealed tokens.
|
never handles a password to sign someone in, and holds only sealed tokens.
|
||||||
- **Ten interface languages and twelve themes.**
|
- **Eleven interface languages and twelve themes.**
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
|||||||
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||||
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||||
@@ -141,22 +141,22 @@ function AuthedApp() {
|
|||||||
* Once per account, not once per mount: this subtree is keyed on the
|
* Once per account, not once per mount: this subtree is keyed on the
|
||||||
* language version, so picking a language throws it away and builds it
|
* language version, so picking a language throws it away and builds it
|
||||||
* again. Re-reading the settings file there would apply a copy written
|
* again. Re-reading the settings file there would apply a copy written
|
||||||
* before the change and undo it.
|
* before the change and undo it. And the load is not this mount's to
|
||||||
|
* cancel: the settings file choosing a language remounts the tree midway
|
||||||
|
* through it, and a load cut off there never armed the pushes, so nothing
|
||||||
|
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
|
||||||
|
* it to the end and lets every mount wait on it.
|
||||||
*/
|
*/
|
||||||
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
|
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsAlreadyLoadedFor(accountId)) {
|
|
||||||
setReady(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
void (async () => {
|
void loadSettingsOnce(accountId, async (isCurrent) => {
|
||||||
/* Before the account's own settings, so both the seeding below and the
|
/* Before the account's own settings, so both the seeding below and the
|
||||||
enforcement inside `hydrate` have something to apply. */
|
enforcement inside `hydrate` have something to apply. */
|
||||||
await loadSettingsPolicy();
|
await loadSettingsPolicy();
|
||||||
if (canceled) return;
|
if (!isCurrent()) return;
|
||||||
const remote = await loadRemoteSettings();
|
const remote = await loadRemoteSettings();
|
||||||
if (canceled) return;
|
if (!isCurrent()) return;
|
||||||
if (remote) useSettings.getState().hydrate(remote);
|
if (remote) useSettings.getState().hydrate(remote);
|
||||||
// No settings file: this account has never had settings of its own, so
|
// No settings file: this account has never had settings of its own, so
|
||||||
// the installation's defaults are what it starts on rather than
|
// the installation's defaults are what it starts on rather than
|
||||||
@@ -178,15 +178,16 @@ function AuthedApp() {
|
|||||||
// The catalog for whatever language that turned out to be. Hydrating
|
// The catalog for whatever language that turned out to be. Hydrating
|
||||||
// asks for it; this is waiting for the answer.
|
// asks for it; this is waiting for the answer.
|
||||||
await whenLanguageReady();
|
await whenLanguageReady();
|
||||||
if (canceled) return;
|
if (!isCurrent()) return;
|
||||||
setReady(true);
|
|
||||||
// Pushes were held back until now so they could not race the load. A
|
// Pushes were held back until now so they could not race the load. A
|
||||||
// change made while it was in flight was kept, and goes out here.
|
// change made while it was in flight was kept, and goes out here.
|
||||||
armSettingsSync();
|
armSettingsSync();
|
||||||
// No file yet — seed one from what this browser has, so the next device
|
// No file yet — seed one from what this browser has, so the next device
|
||||||
// to sign in starts from these rather than from the defaults.
|
// to sign in starts from these rather than from the defaults.
|
||||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||||
})();
|
}).then(() => {
|
||||||
|
if (!canceled) setReady(true);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
||||||
import { isAppFolder } from "../appFolder";
|
import { isAppFolder } from "../appFolder";
|
||||||
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
import { loadSettingsOnce, stopSettingsSync } from "../settingsSync";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings used to live only in localStorage, so nothing followed the user
|
* Settings used to live only in localStorage, so nothing followed the user
|
||||||
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
|
|||||||
expect(merged.uiLanguage).toBe("en");
|
expect(merged.uiLanguage).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the file again for an account after a sign-out", () => {
|
it("reads the file again for an account after a sign-out", async () => {
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
let loads = 0;
|
||||||
|
const load = async () => { loads++; };
|
||||||
|
await loadSettingsOnce("a1", load);
|
||||||
// The remount that a language change causes must not read it a second time.
|
// The remount that a language change causes must not read it a second time.
|
||||||
expect(settingsAlreadyLoadedFor("a1")).toBe(true);
|
await loadSettingsOnce("a1", load);
|
||||||
|
expect(loads).toBe(1);
|
||||||
// Signing out drops the claim, so signing back in reads the file rather
|
// Signing out drops the claim, so signing back in reads the file rather
|
||||||
// than trusting whatever the previous session left behind.
|
// than trusting whatever the previous session left behind.
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
await loadSettingsOnce("a1", load);
|
||||||
|
expect(loads).toBe(2);
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a missing account as already loaded, so nothing is fetched", () => {
|
it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
|
||||||
expect(settingsAlreadyLoadedFor(null)).toBe(true);
|
stopSettingsSync();
|
||||||
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => { release = r; });
|
||||||
|
let loads = 0;
|
||||||
|
let armed = false;
|
||||||
|
const load = async (isCurrent: () => boolean) => {
|
||||||
|
loads++;
|
||||||
|
await gate;
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
armed = true;
|
||||||
|
};
|
||||||
|
// First mount starts the load; the settings file picks a language and the
|
||||||
|
// tree remounts before the load has finished.
|
||||||
|
const first = loadSettingsOnce("a1", load);
|
||||||
|
const second = loadSettingsOnce("a1", load);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
release();
|
||||||
|
await second;
|
||||||
|
expect(loads).toBe(1);
|
||||||
|
// This is what used to be skipped: the remounted tree took the
|
||||||
|
// already-loaded path and nothing armed the pushes.
|
||||||
|
expect(armed).toBe(true);
|
||||||
|
stopSettingsSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a load that a sign-out overtakes", async () => {
|
||||||
|
stopSettingsSync();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => { release = r; });
|
||||||
|
let applied = false;
|
||||||
|
const pending = loadSettingsOnce("a1", async (isCurrent) => {
|
||||||
|
await gate;
|
||||||
|
if (isCurrent()) applied = true;
|
||||||
|
});
|
||||||
|
stopSettingsSync();
|
||||||
|
release();
|
||||||
|
await pending;
|
||||||
|
expect(applied).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing account as already loaded, so nothing is fetched", async () => {
|
||||||
|
let loads = 0;
|
||||||
|
await loadSettingsOnce(null, async () => { loads++; });
|
||||||
|
await loadSettingsOnce(undefined, async () => { loads++; });
|
||||||
|
expect(loads).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -48,6 +48,7 @@ export const UI_LANGUAGES: readonly UiLanguage[] = [
|
|||||||
{ tag: "ru", name: "Русский" },
|
{ tag: "ru", name: "Русский" },
|
||||||
{ tag: "uk", name: "Українська" },
|
{ tag: "uk", name: "Українська" },
|
||||||
{ tag: "zh-Hans", name: "简体中文" },
|
{ tag: "zh-Hans", name: "简体中文" },
|
||||||
|
{ tag: "tr", name: "Türkçe" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Where to report a bad translation. Beta languages depend on it. */
|
/** Where to report a bad translation. Beta languages depend on it. */
|
||||||
|
|||||||
+28
-10
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
|
|||||||
/** Nothing is pushed before the first load has settled, or we would race it. */
|
/** Nothing is pushed before the first load has settled, or we would race it. */
|
||||||
let armed = false;
|
let armed = false;
|
||||||
let loadedFor: string | null = null;
|
let loadedFor: string | null = null;
|
||||||
|
let loading: Promise<void> | null = null;
|
||||||
|
/** Bumped by every new load and every sign-out; a load checks it is still the latest. */
|
||||||
|
let generation = 0;
|
||||||
let listenersBound = false;
|
let listenersBound = false;
|
||||||
|
|
||||||
export function settingsSyncAvailable(): boolean {
|
export function settingsSyncAvailable(): boolean {
|
||||||
@@ -64,21 +67,34 @@ export async function loadRemoteSettings(): Promise<Record<string, unknown> | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Has this account's settings file already been read on this page load?
|
* Load this account's settings, once per page load, however many times the
|
||||||
|
* caller mounts.
|
||||||
*
|
*
|
||||||
* Claims the account as a side effect, so two callers cannot both start a
|
* The subtree that does the reading is keyed on the language version, so it
|
||||||
* read. The subtree that does the reading is keyed on the language version
|
* is remounted whenever the language changes -- including when the settings
|
||||||
* and so is deliberately remounted whenever somebody picks a language;
|
* file itself picks one. Tying the load to a mount meant that remount canceled
|
||||||
* without this the remount re-reads a file written before the change and
|
* it halfway: the new mount saw the account already claimed and skipped the
|
||||||
* applies it, putting the old language back.
|
* load, and nothing armed the pushes, so every later change was silently
|
||||||
|
* dropped (Gitea issue #23). Now the load belongs to the account, not the
|
||||||
|
* mount: every mount waits on the same promise, and the load runs to the end.
|
||||||
*
|
*
|
||||||
|
* Re-reading on a remount is still wrong -- it would apply a file written
|
||||||
|
* before the change and undo it -- which is why it is shared, not repeated.
|
||||||
|
*
|
||||||
|
* `isCurrent` turns false once the session that started the load signs out,
|
||||||
|
* so a load overtaken by a sign-out stops instead of applying stale settings.
|
||||||
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
|
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
|
||||||
*/
|
*/
|
||||||
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean {
|
export function loadSettingsOnce(
|
||||||
if (!accountId) return true;
|
accountId: string | null | undefined,
|
||||||
if (loadedFor === accountId) return true;
|
load: (isCurrent: () => boolean) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!accountId) return Promise.resolve();
|
||||||
|
if (loadedFor === accountId && loading) return loading;
|
||||||
loadedFor = accountId;
|
loadedFor = accountId;
|
||||||
return false;
|
const mine = ++generation;
|
||||||
|
loading = load(() => generation === mine);
|
||||||
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Allow pushes. Called once the first load has settled, either way. */
|
/** Allow pushes. Called once the first load has settled, either way. */
|
||||||
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
|
|||||||
export function stopSettingsSync(): void {
|
export function stopSettingsSync(): void {
|
||||||
armed = false;
|
armed = false;
|
||||||
loadedFor = null;
|
loadedFor = null;
|
||||||
|
loading = null;
|
||||||
|
generation++;
|
||||||
pending = null;
|
pending = null;
|
||||||
if (timer !== null) {
|
if (timer !== null) {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
|
|||||||
@@ -387,3 +387,16 @@ describe("the containment that mail CSS cannot override", () => {
|
|||||||
expect(rule).toMatch(/contain\s*:\s*layout/);
|
expect(rule).toMatch(/contain\s*:\s*layout/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mail wider than the reading pane", () => {
|
||||||
|
/*
|
||||||
|
* The root keeps `contain: content`, which clips what overflows, so without
|
||||||
|
* a scroll the right side of a min-width table was simply gone, worst on a
|
||||||
|
* phone (Gitea issue #24). jsdom does no layout, so this pins the rule.
|
||||||
|
*/
|
||||||
|
it("scrolls sideways instead of being cut off", () => {
|
||||||
|
const root = EMAIL_BASE_CSS.match(/\.ihm-email-root \{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
expect(root).toMatch(/contain:\s*content/);
|
||||||
|
expect(root).toMatch(/overflow-x:\s*auto/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -287,7 +287,10 @@ export function sanitizeEditorHtml(input: string): string {
|
|||||||
export const EMAIL_BASE_CSS = `
|
export const EMAIL_BASE_CSS = `
|
||||||
:host { display:block; color-scheme: light; }
|
:host { display:block; color-scheme: light; }
|
||||||
:host(.themed) { color-scheme: inherit; }
|
:host(.themed) { color-scheme: inherit; }
|
||||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
|
/* contain: content clips what overflows, so mail built wider than the pane --
|
||||||
|
a min-width table, a nowrap cell -- was cut off with no way to reach the
|
||||||
|
rest. It scrolls sideways instead (Gitea issue #24). */
|
||||||
|
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; overflow-x:auto; overflow-y:hidden; }
|
||||||
.ihm-email-root img { max-width:100%; height:auto; }
|
.ihm-email-root img { max-width:100%; height:auto; }
|
||||||
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
||||||
.ihm-email-root table { max-width:100%; }
|
.ihm-email-root table { max-width:100%; }
|
||||||
|
|||||||
@@ -812,6 +812,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Schriftgröße",
|
"Text size": "Schriftgröße",
|
||||||
"Font size": "Schriftgröße",
|
"Font size": "Schriftgröße",
|
||||||
"Small": "Klein",
|
"Small": "Klein",
|
||||||
|
"Original size": "Originalgröße",
|
||||||
|
"Drag to resize": "Zum Ändern der Größe ziehen",
|
||||||
|
"Image size": "Bildgröße",
|
||||||
"Medium": "Mittel",
|
"Medium": "Mittel",
|
||||||
"Large": "Groß",
|
"Large": "Groß",
|
||||||
"Huge": "Sehr groß",
|
"Huge": "Sehr groß",
|
||||||
|
|||||||
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Tamaño del texto",
|
"Text size": "Tamaño del texto",
|
||||||
"Font size": "Tamaño de letra",
|
"Font size": "Tamaño de letra",
|
||||||
"Small": "Pequeño",
|
"Small": "Pequeño",
|
||||||
|
"Original size": "Tamaño original",
|
||||||
|
"Drag to resize": "Arrastra para cambiar el tamaño",
|
||||||
|
"Image size": "Tamaño de la imagen",
|
||||||
"Medium": "Mediano",
|
"Medium": "Mediano",
|
||||||
"Large": "Grande",
|
"Large": "Grande",
|
||||||
"Huge": "Muy grande",
|
"Huge": "Muy grande",
|
||||||
|
|||||||
@@ -814,6 +814,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Taille du texte",
|
"Text size": "Taille du texte",
|
||||||
"Font size": "Taille de police",
|
"Font size": "Taille de police",
|
||||||
"Small": "Petite",
|
"Small": "Petite",
|
||||||
|
"Original size": "Taille d’origine",
|
||||||
|
"Drag to resize": "Faites glisser pour redimensionner",
|
||||||
|
"Image size": "Taille de l’image",
|
||||||
"Medium": "Moyenne",
|
"Medium": "Moyenne",
|
||||||
"Large": "Grande",
|
"Large": "Grande",
|
||||||
"Huge": "Très grande",
|
"Huge": "Très grande",
|
||||||
|
|||||||
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "文字サイズ",
|
"Text size": "文字サイズ",
|
||||||
"Font size": "フォントサイズ",
|
"Font size": "フォントサイズ",
|
||||||
"Small": "小",
|
"Small": "小",
|
||||||
|
"Original size": "元のサイズ",
|
||||||
|
"Drag to resize": "ドラッグしてサイズを変更",
|
||||||
|
"Image size": "画像のサイズ",
|
||||||
"Medium": "中",
|
"Medium": "中",
|
||||||
"Large": "大",
|
"Large": "大",
|
||||||
"Huge": "特大",
|
"Huge": "特大",
|
||||||
|
|||||||
@@ -809,6 +809,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Tekstgrootte",
|
"Text size": "Tekstgrootte",
|
||||||
"Font size": "Lettergrootte",
|
"Font size": "Lettergrootte",
|
||||||
"Small": "Klein",
|
"Small": "Klein",
|
||||||
|
"Original size": "Oorspronkelijke grootte",
|
||||||
|
"Drag to resize": "Sleep om het formaat te wijzigen",
|
||||||
|
"Image size": "Afbeeldingsgrootte",
|
||||||
"Medium": "Middel",
|
"Medium": "Middel",
|
||||||
"Large": "Groot",
|
"Large": "Groot",
|
||||||
"Huge": "Zeer groot",
|
"Huge": "Zeer groot",
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import type { PermissionCatalog } from "@/lib/permissionLabels";
|
||||||
|
|
||||||
|
/** Stalwart 0.16.22's permission labels in Turkish, keyed by permission name. Checked against source.json. */
|
||||||
|
export const permissionCatalog: PermissionCatalog = {
|
||||||
|
categories: {
|
||||||
|
"AI models Management": "Yapay Zeka Modelleri Yönetimi",
|
||||||
|
"API keys Management": "API Anahtarları Yönetimi",
|
||||||
|
"Accounts Management": "Hesap Yönetimi",
|
||||||
|
"Action": "Eylem",
|
||||||
|
"Actions Management": "Eylem Yönetimi",
|
||||||
|
"Addresses Management": "Adres Yönetimi",
|
||||||
|
"Alerts Management": "Uyarı Yönetimi",
|
||||||
|
"App passwords Management": "Uygulama Şifreleri Yönetimi",
|
||||||
|
"Applications Management": "Uygulama Yönetimi",
|
||||||
|
"Archived items Management": "Arşivlenmiş Ögeler Yönetimi",
|
||||||
|
"Calendar": "Takvim",
|
||||||
|
"Certificates Management": "Sertifika Yönetimi",
|
||||||
|
"Cluster nodes Management": "Küme Düğümleri Yönetimi",
|
||||||
|
"DNS servers Management": "DNS Sunucuları Yönetimi",
|
||||||
|
"Directories Management": "Dizin Yönetimi",
|
||||||
|
"Domains Management": "Alan Adı Yönetimi",
|
||||||
|
"Email": "E-posta",
|
||||||
|
"Events Management": "Etkinlik Yönetimi",
|
||||||
|
"Extensions Management": "Uzantı Yönetimi",
|
||||||
|
"Hooks Management": "Kanca (Hook) Yönetimi",
|
||||||
|
"IMAP": "IMAP",
|
||||||
|
"JMAP": "JMAP",
|
||||||
|
"Keys Management": "Anahtar Yönetimi",
|
||||||
|
"Listeners Management": "Dinleyici (Listener) Yönetimi",
|
||||||
|
"Lists Management": "Liste Yönetimi",
|
||||||
|
"Log entries Management": "Günlük Kayıtları Yönetimi",
|
||||||
|
"Lookups Management": "Arama (Lookup) Yönetimi",
|
||||||
|
"Mailing lists Management": "E-posta Listeleri Yönetimi",
|
||||||
|
"ManageSieve": "ManageSieve",
|
||||||
|
"Masked emails Management": "Maskelenmiş E-posta Yönetimi",
|
||||||
|
"Messages Management": "İleti Yönetimi",
|
||||||
|
"Metrics Management": "Metrik Yönetimi",
|
||||||
|
"Milters Management": "Milter Yönetimi",
|
||||||
|
"OAuth clients Management": "OAuth İstemcileri Yönetimi",
|
||||||
|
"POP3": "POP3",
|
||||||
|
"Providers Management": "Sağlayıcı Yönetimi",
|
||||||
|
"Public keys Management": "Genel Anahtar Yönetimi",
|
||||||
|
"Queues Management": "Kuyruk Yönetimi",
|
||||||
|
"Quotas Management": "Kota Yönetimi",
|
||||||
|
"Reports Management": "Rapor Yönetimi",
|
||||||
|
"Roles Management": "Rol Yönetimi",
|
||||||
|
"Routes Management": "Rota Yönetimi",
|
||||||
|
"Rules Management": "Kural Yönetimi",
|
||||||
|
"Samples Management": "Örnek Yönetimi",
|
||||||
|
"Schedules Management": "Zamanlama Yönetimi",
|
||||||
|
"Scripts Management": "Betik Yönetimi",
|
||||||
|
"Servers Management": "Sunucu Yönetimi",
|
||||||
|
"Settings": "Ayarlar",
|
||||||
|
"Signatures Management": "İmza Yönetimi",
|
||||||
|
"Strategies Management": "Strateji Yönetimi",
|
||||||
|
"Tags Management": "Etiket Yönetimi",
|
||||||
|
"Task": "Görev",
|
||||||
|
"Tasks Management": "Görev Yönetimi",
|
||||||
|
"Tenants Management": "Kiracı (Tenant) Yönetimi",
|
||||||
|
"Throttles Management": "Hız Sınırlama (Throttle) Yönetimi",
|
||||||
|
"Tracers Management": "İzleyici (Tracer) Yönetimi",
|
||||||
|
"Traces Management": "İzleme Kayıtları (Trace) Yönetimi",
|
||||||
|
"WebDAV": "WebDAV",
|
||||||
|
"Webhooks Management": "Webhook Yönetimi",
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
authenticate: "Sunucuya giriş yap",
|
||||||
|
authenticateWithAlias: "E-posta takma adları kullanarak giriş yap",
|
||||||
|
interactAi: "Yapay zeka modelleriyle etkileşim kur",
|
||||||
|
impersonate: "Başka bir kullanıcı adına işlem yap",
|
||||||
|
unlimitedRequests: "İstek hız sınırlarını atla",
|
||||||
|
unlimitedUploads: "Yükleme boyutu ve hız sınırlarını atla",
|
||||||
|
fetchAnyBlob: "Hesaba ait olmayanlar dahil herhangi bir blob nesnesini al",
|
||||||
|
emailSend: "E-postaları gönder",
|
||||||
|
emailReceive: "E-postaları al",
|
||||||
|
calendarAlarmsSend: "Takvim anımsatıcı bildirimlerini e-posta ile gönder",
|
||||||
|
calendarSchedulingSend: "Takvim davetlerini ve güncellemelerini e-posta ile gönder",
|
||||||
|
calendarSchedulingReceive: "Gelen takvim davetlerini ve yanıtlarını işle",
|
||||||
|
jmapPushSubscriptionGet: "Push aboneliğini ayrıntılarını al",
|
||||||
|
jmapPushSubscriptionCreate: "Yeni push aboneliklerini oluştur",
|
||||||
|
jmapPushSubscriptionUpdate: "Mevcut push aboneliklerini düzenle",
|
||||||
|
jmapPushSubscriptionDestroy: "Push aboneliklerini kaldır",
|
||||||
|
jmapMailboxGet: "Posta kutusunu ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapMailboxChanges: "Belirli bir durumdan bu yana posta kutularını üzerindeki değişiklikleri izle",
|
||||||
|
jmapMailboxQuery: "Kriterlerle eşleşen posta kutularını ara",
|
||||||
|
jmapMailboxQueryChanges: "Posta kutusunu sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapMailboxCreate: "Yeni posta kutularını oluştur",
|
||||||
|
jmapMailboxUpdate: "Mevcut posta kutularını düzenle",
|
||||||
|
jmapMailboxDestroy: "Posta kutularını kaldır",
|
||||||
|
jmapThreadGet: "Email thread ayrıntılarını al",
|
||||||
|
jmapThreadChanges: "Belirli bir durumdan bu yana yazışmaları üzerindeki değişiklikleri izle",
|
||||||
|
jmapEmailGet: "E-postayı ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapEmailChanges: "Belirli bir durumdan bu yana e-postaları üzerindeki değişiklikleri izle",
|
||||||
|
jmapEmailQuery: "Kriterlerle eşleşen e-postaları ara",
|
||||||
|
jmapEmailQueryChanges: "E-postayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapEmailCreate: "Yeni e-postaları oluştur",
|
||||||
|
jmapEmailUpdate: "Mevcut e-postaları düzenle",
|
||||||
|
jmapEmailDestroy: "E-postaları kaldır",
|
||||||
|
jmapEmailCopy: "E-postaları başka bir hesaba kopyala",
|
||||||
|
jmapEmailImport: "Blob verilerinden e-postaları içe aktar",
|
||||||
|
jmapEmailParse: "Ham e-posta verilerini kaydetmeden ayrıştır",
|
||||||
|
jmapSearchSnippetGet: "E-postalar için arama sonucu pasajlarını al",
|
||||||
|
jmapIdentityGet: "Gönderen kimliğini ayrıntılarını al",
|
||||||
|
jmapIdentityChanges: "Belirli bir durumdan bu yana kimlikleri üzerindeki değişiklikleri izle",
|
||||||
|
jmapIdentityCreate: "Yeni gönderen kimliklerini oluştur",
|
||||||
|
jmapIdentityUpdate: "Mevcut gönderen kimliklerini düzenle",
|
||||||
|
jmapIdentityDestroy: "Gönderen kimliklerini kaldır",
|
||||||
|
jmapEmailSubmissionGet: "E-posta gönderimini ayrıntılarını al",
|
||||||
|
jmapEmailSubmissionChanges: "Belirli bir durumdan bu yana e-posta gönderimlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapEmailSubmissionQuery: "Kriterlerle eşleşen e-posta gönderimlerini ara",
|
||||||
|
jmapEmailSubmissionQueryChanges: "E-posta gönderimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapEmailSubmissionCreate: "Yeni email submissions for sending oluştur",
|
||||||
|
jmapEmailSubmissionUpdate: "Mevcut e-posta gönderimlerini düzenle",
|
||||||
|
jmapEmailSubmissionDestroy: "E-posta gönderimlerini kaldır",
|
||||||
|
jmapVacationResponseGet: "Tatil yanıtı yapılandırmasını al",
|
||||||
|
jmapVacationResponseCreate: "Tatil yanıtı ayarlarını oluştur",
|
||||||
|
jmapVacationResponseUpdate: "Tatil / otomatik yanıt ayarlarını düzenle",
|
||||||
|
jmapVacationResponseDestroy: "Tatil yanıtı ayarlarını kaldır",
|
||||||
|
jmapSieveScriptGet: "Sieve filtre betiğini ayrıntılarını al",
|
||||||
|
jmapSieveScriptQuery: "Kriterlerle eşleşen Sieve betiklerini ara",
|
||||||
|
jmapSieveScriptValidate: "Bir Sieve betiğini kaydetmeden doğrula",
|
||||||
|
jmapSieveScriptCreate: "Yeni Sieve filtre betiklerini oluştur",
|
||||||
|
jmapSieveScriptUpdate: "Mevcut Sieve filtre betiklerini düzenle",
|
||||||
|
jmapSieveScriptDestroy: "Sieve filtre betiklerini kaldır",
|
||||||
|
jmapPrincipalGet: "Kullanıcıyı/grubu (principal) ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapPrincipalQuery: "Kriterlerle eşleşen kullanıcıları/grupları (principal) ara",
|
||||||
|
jmapPrincipalChanges: "Belirli bir durumdan bu yana kullanıcıları/grupları (principal) üzerindeki değişiklikleri izle",
|
||||||
|
jmapPrincipalQueryChanges: "Kullanıcıyı/grubu (principal) sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapPrincipalGetAvailability: "Availability information for principals sorgula",
|
||||||
|
jmapPrincipalCreate: "Yeni kullanıcıları/grupları (principal) oluştur",
|
||||||
|
jmapPrincipalUpdate: "Mevcut kullanıcıları/grupları (principal) düzenle",
|
||||||
|
jmapPrincipalDestroy: "Kullanıcıları/grupları (principal) kaldır",
|
||||||
|
jmapQuotaGet: "Kota kullanımını ve sınırlarını al",
|
||||||
|
jmapQuotaChanges: "Belirli bir durumdan bu yana kotaları üzerindeki değişiklikleri izle",
|
||||||
|
jmapQuotaQuery: "Kriterlerle eşleşen kotaları ara",
|
||||||
|
jmapQuotaQueryChanges: "Kotayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapBlobGet: "Blob data indir",
|
||||||
|
jmapBlobCopy: "Blob nesnelerini başka bir hesaba kopyala",
|
||||||
|
jmapBlobLookup: "Veri türleri genelinde blob kullanımını sorgula",
|
||||||
|
jmapBlobUpload: "New blob data yükle",
|
||||||
|
jmapAddressBookGet: "Adres defterini ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapAddressBookChanges: "Belirli bir durumdan bu yana adres defterlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapAddressBookCreate: "Yeni adres defterlerini oluştur",
|
||||||
|
jmapAddressBookUpdate: "Mevcut adres defterlerini düzenle",
|
||||||
|
jmapAddressBookDestroy: "Adres defterlerini kaldır",
|
||||||
|
jmapContactCardGet: "Kişi kartını ayrıntılarını al",
|
||||||
|
jmapContactCardChanges: "Belirli bir durumdan bu yana kişi kartlarını üzerindeki değişiklikleri izle",
|
||||||
|
jmapContactCardQuery: "Kriterlerle eşleşen kişi kartlarını ara",
|
||||||
|
jmapContactCardQueryChanges: "Kişi kartını sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapContactCardCreate: "Yeni kişi kartlarını oluştur",
|
||||||
|
jmapContactCardUpdate: "Mevcut kişi kartlarını düzenle",
|
||||||
|
jmapContactCardDestroy: "Kişi kartlarını kaldır",
|
||||||
|
jmapContactCardCopy: "Kişi kartlarını başka bir hesaba kopyala",
|
||||||
|
jmapContactCardParse: "Ham kişi kartı verilerini kaydetmeden ayrıştır",
|
||||||
|
jmapFileNodeGet: "Dosya düğümünü ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapFileNodeChanges: "Belirli bir durumdan bu yana dosya düğümlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapFileNodeQuery: "Kriterlerle eşleşen dosya düğümlerini ara",
|
||||||
|
jmapFileNodeQueryChanges: "Dosya düğümünü sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapFileNodeCreate: "Yeni dosya düğümlerini oluştur",
|
||||||
|
jmapFileNodeUpdate: "Mevcut dosya düğümlerini düzenle",
|
||||||
|
jmapFileNodeDestroy: "Dosya düğümlerini kaldır",
|
||||||
|
jmapFileNodeCopy: "Dosya düğümlerini başka bir hesaba kopyala",
|
||||||
|
jmapShareNotificationGet: "Paylaşım bildirimini ayrıntılarını al",
|
||||||
|
jmapShareNotificationChanges: "Belirli bir durumdan bu yana paylaşım bildirimlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapShareNotificationQuery: "Kriterlerle eşleşen paylaşım bildirimlerini ara",
|
||||||
|
jmapShareNotificationQueryChanges: "Paylaşım bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapShareNotificationCreate: "Yeni paylaşım bildirimlerini oluştur",
|
||||||
|
jmapShareNotificationUpdate: "Mevcut paylaşım bildirimlerini düzenle",
|
||||||
|
jmapShareNotificationDestroy: "Paylaşım bildirimlerini kaldır",
|
||||||
|
jmapCalendarGet: "Takvimi ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapCalendarChanges: "Belirli bir durumdan bu yana takvimleri üzerindeki değişiklikleri izle",
|
||||||
|
jmapCalendarCreate: "Yeni takvimleri oluştur",
|
||||||
|
jmapCalendarUpdate: "Mevcut takvimleri düzenle",
|
||||||
|
jmapCalendarDestroy: "Takvimleri kaldır",
|
||||||
|
jmapCalendarEventGet: "Takvim etkinliğini ayrıntılarını al",
|
||||||
|
jmapCalendarEventChanges: "Belirli bir durumdan bu yana takvim etkinliklerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapCalendarEventQuery: "Kriterlerle eşleşen takvim etkinliklerini ara",
|
||||||
|
jmapCalendarEventQueryChanges: "Takvim etkinliğini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapCalendarEventCreate: "Yeni takvim etkinliklerini oluştur",
|
||||||
|
jmapCalendarEventUpdate: "Mevcut takvim etkinliklerini düzenle",
|
||||||
|
jmapCalendarEventDestroy: "Takvim etkinliklerini kaldır",
|
||||||
|
jmapCalendarEventCopy: "Takvim etkinliklerini başka bir hesaba kopyala",
|
||||||
|
jmapCalendarEventParse: "Ham takvim etkinliği verilerini kaydetmeden ayrıştır",
|
||||||
|
jmapCalendarEventNotificationGet: "Takvim etkinliği bildirimini ayrıntılarını al",
|
||||||
|
jmapCalendarEventNotificationChanges: "Belirli bir durumdan bu yana takvim etkinliği bildirimlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapCalendarEventNotificationQuery: "Kriterlerle eşleşen takvim etkinliği bildirimlerini ara",
|
||||||
|
jmapCalendarEventNotificationQueryChanges: "Takvim etkinliği bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapCalendarEventNotificationCreate: "Yeni takvim etkinliği bildirimlerini oluştur",
|
||||||
|
jmapCalendarEventNotificationUpdate: "Mevcut takvim etkinliği bildirimlerini düzenle",
|
||||||
|
jmapCalendarEventNotificationDestroy: "Takvim etkinliği bildirimlerini kaldır",
|
||||||
|
jmapParticipantIdentityGet: "Participant identity ayrıntılarını al",
|
||||||
|
jmapParticipantIdentityChanges: "Belirli bir durumdan bu yana participant identities üzerindeki değişiklikleri izle",
|
||||||
|
jmapParticipantIdentityCreate: "Yeni participant identities oluştur",
|
||||||
|
jmapParticipantIdentityUpdate: "Mevcut participant identities düzenle",
|
||||||
|
jmapParticipantIdentityDestroy: "Participant identities kaldır",
|
||||||
|
jmapCoreEcho: "Test amacıyla giriş verisini geri yankıla (echo)",
|
||||||
|
imapAuthenticate: "IMAP sunucusuna giriş yap",
|
||||||
|
imapAclGet: "Posta kutusu erişim kontrol listelerini (ACL) görüntüle",
|
||||||
|
imapAclSet: "Posta kutusu erişim kontrol listelerini (ACL) düzenle",
|
||||||
|
imapMyRights: "Bir posta kutusundaki kendi yetkilerini görüntüle",
|
||||||
|
imapListRights: "Bir posta kutusu için kullanılabilir yetki bayraklarını listele",
|
||||||
|
imapAppend: "Messages to a mailbox yükle",
|
||||||
|
imapCapability: "Supported imap extensions sorgula",
|
||||||
|
imapId: "Server identification and version sorgula",
|
||||||
|
imapCopy: "Posta kutuları arasında iletileri kopyala",
|
||||||
|
imapMove: "Posta kutuları arasında iletileri taşı",
|
||||||
|
imapCreate: "Yeni posta kutularını oluştur",
|
||||||
|
imapDelete: "Mailboxes or mark messages for deletion sil",
|
||||||
|
imapEnable: "İsteğe bağlı IMAP uzantılarını etkinleştir",
|
||||||
|
imapExpunge: "Silinmek üzere işaretlenmiş iletileri kalıcı olarak kaldır (expunge)",
|
||||||
|
imapFetch: "Message content and metadata indir",
|
||||||
|
imapIdle: "Posta kutusu değişiklikleri için gerçek zamanlı bildirimler al (idle)",
|
||||||
|
imapList: "Kullanılabilir posta kutularını ve özniteliklerini listele",
|
||||||
|
imapLsub: "Kullanıcının abone olduğu posta kutularını listele",
|
||||||
|
imapNamespace: "Available mailbox namespace prefixes sorgula",
|
||||||
|
imapRename: "Posta kutularını yeniden adlandır veya taşı",
|
||||||
|
imapSearch: "Kriterlere göre iletileri ara",
|
||||||
|
imapSort: "İletileri belirli bir sıralama düzeninde al",
|
||||||
|
imapSelect: "Bir posta kutusunu okuma-yazma erişimi için aç",
|
||||||
|
imapExamine: "Bir posta kutusunu salt okunur erişim için aç",
|
||||||
|
imapStatus: "Mailbox message counts and state sorgula",
|
||||||
|
imapStore: "İleti bayraklarını ayarla veya temizle (okundu, yıldızlı, silindi vb.)",
|
||||||
|
imapSubscribe: "Posta kutularına abone ol veya abonelikten çık",
|
||||||
|
imapThread: "İletileri yazışma dizileri halinde grupla",
|
||||||
|
pop3Authenticate: "POP3 sunucusuna giriş yap",
|
||||||
|
pop3List: "İletileri ve boyutlarını listele",
|
||||||
|
pop3Uidl: "Benzersiz ileti tanımlayıcılarını al (UIDL)",
|
||||||
|
pop3Stat: "Mailbox message count and total size sorgula",
|
||||||
|
pop3Retr: "Message content indir",
|
||||||
|
pop3Dele: "Bağlantı kesildiğinde silinmek üzere iletileri işaretle",
|
||||||
|
sieveAuthenticate: "ManageSieve sunucusuna giriş yap",
|
||||||
|
sieveListScripts: "Yüklenen Sieve betiklerini listele",
|
||||||
|
sieveSetActive: "Hangi Sieve betiğinin aktif olduğunu belirle",
|
||||||
|
sieveGetScript: "Sieve script content indir",
|
||||||
|
sievePutScript: "Or replace a sieve script yükle",
|
||||||
|
sieveDeleteScript: "A sieve script kaldır",
|
||||||
|
sieveRenameScript: "Bir Sieve betiğini yeniden adlandır",
|
||||||
|
sieveCheckScript: "Sieve betiği sözdizimini kaydetmeden doğrula",
|
||||||
|
sieveHaveSpace: "Kotanın belirli boyuttaki bir betiği yüklemeye izin verip vermediğini denetle",
|
||||||
|
davSyncCollection: "Koleksiyon değişikliklerini istemciyle eşitle",
|
||||||
|
davExpandProperty: "Diğer kaynaklara başvuran özellikleri genişlet",
|
||||||
|
davPrincipalAcl: "Kullanıcılar/gruplar (principal) üzerindeki erişim kontrol özelliklerini yönet",
|
||||||
|
davPrincipalList: "Sistemdeki kullanılabilir kullanıcıları/grupları (principal) listele",
|
||||||
|
davPrincipalMatch: "Belirtilen kriterlere göre kullanıcıları/grupları (principal) eşleştir",
|
||||||
|
davPrincipalSearch: "Özellik değerlerine göre kullanıcıları/grupları (principal) ara",
|
||||||
|
davPrincipalSearchPropSet: "Which properties can be searched on principals sorgula",
|
||||||
|
davFilePropFind: "Dosya kaynaklarının özelliklerini al",
|
||||||
|
davFilePropPatch: "Dosya kaynaklarının özelliklerini düzenle",
|
||||||
|
davFileGet: "File resources indir",
|
||||||
|
davFileMkCol: "Yeni file collections or directories oluştur",
|
||||||
|
davFileDelete: "File resources kaldır",
|
||||||
|
davFilePut: "Or modify file resources yükle",
|
||||||
|
davFileCopy: "Dosya kaynaklarını yeni konumlara kopyala",
|
||||||
|
davFileMove: "Dosya kaynaklarını yeni konumlara taşı",
|
||||||
|
davFileLock: "Eşzamanlı değişiklikleri önlemek için dosya kaynaklarını kilitle",
|
||||||
|
davFileAcl: "Dosya kaynakları için erişim kontrol listelerini (ACL) yönet",
|
||||||
|
davCardPropFind: "Adres defteri kayıtlarının özelliklerini al",
|
||||||
|
davCardPropPatch: "Adres defteri kayıtlarının özelliklerini düzenle",
|
||||||
|
davCardGet: "Address book entries indir",
|
||||||
|
davCardMkCol: "Yeni address book collections oluştur",
|
||||||
|
davCardDelete: "Address book entries or collections kaldır",
|
||||||
|
davCardPut: "Or modify address book entries yükle",
|
||||||
|
davCardCopy: "Adres defteri kayıtlarını yeni konumlara kopyala",
|
||||||
|
davCardMove: "Adres defteri kayıtlarını yeni konumlara taşı",
|
||||||
|
davCardLock: "Eşzamanlı değişiklikleri önlemek için adres defteri kayıtlarını kilitle",
|
||||||
|
davCardAcl: "Adres defteri kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||||
|
davCardQuery: "Kriterlerle eşleşen address book entries ara",
|
||||||
|
davCardMultiGet: "Tek bir istekte birden çok adres defteri kaydı al",
|
||||||
|
davCalPropFind: "Takvim kayıtlarının özelliklerini al",
|
||||||
|
davCalPropPatch: "Takvim kayıtlarının özelliklerini düzenle",
|
||||||
|
davCalGet: "Calendar entries indir",
|
||||||
|
davCalMkCol: "Yeni calendar collections oluştur",
|
||||||
|
davCalDelete: "Calendar entries or collections kaldır",
|
||||||
|
davCalPut: "Or modify calendar entries yükle",
|
||||||
|
davCalCopy: "Takvim kayıtlarını yeni konumlara kopyala",
|
||||||
|
davCalMove: "Takvim kayıtlarını yeni konumlara taşı",
|
||||||
|
davCalLock: "Eşzamanlı değişiklikleri önlemek için takvim kayıtlarını kilitle",
|
||||||
|
davCalAcl: "Takvim kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||||
|
davCalQuery: "Kriterlerle eşleşen calendar entries ara",
|
||||||
|
davCalMultiGet: "Tek bir istekte birden çok takvim kaydı al",
|
||||||
|
davCalFreeBusyQuery: "Free/busy time information for scheduling sorgula",
|
||||||
|
oAuthClientRegistration: "Yeni OAuth istemci uygulamaları kaydet",
|
||||||
|
oAuthClientOverride: "OAuth istemci kayıt gereksinimlerini atla",
|
||||||
|
liveTracing: "Gerçek zamanlı sunucu etkinlik izlerini (trace) görüntüle",
|
||||||
|
liveMetrics: "Gerçek zamanlı sunucu performans metriklerini görüntüle",
|
||||||
|
liveDeliveryTest: "Teslimat yapılandırmasını doğrulamak için test e-postaları gönder",
|
||||||
|
scimAccess: "SCIM uç noktası üzerinden kullanıcı ve grup sağla (provision)",
|
||||||
|
sysAccountGet: "Hesapları getir",
|
||||||
|
sysAccountCreate: "Hesapları oluştur",
|
||||||
|
sysAccountUpdate: "Hesapları güncelle",
|
||||||
|
sysAccountDestroy: "Hesapları sil",
|
||||||
|
sysAccountQuery: "Hesapları sorgula",
|
||||||
|
sysAccountPasswordGet: "Account password settings getir",
|
||||||
|
sysAccountPasswordUpdate: "Account password settings güncelle",
|
||||||
|
sysAccountSettingsGet: "Account settings settings getir",
|
||||||
|
sysAccountSettingsUpdate: "Account settings settings güncelle",
|
||||||
|
sysAcmeProviderGet: "Sağlayıcıları getir",
|
||||||
|
sysAcmeProviderCreate: "Sağlayıcıları oluştur",
|
||||||
|
sysAcmeProviderUpdate: "Sağlayıcıları güncelle",
|
||||||
|
sysAcmeProviderDestroy: "Sağlayıcıları sil",
|
||||||
|
sysAcmeProviderQuery: "Sağlayıcıları sorgula",
|
||||||
|
actionReloadSettings: "Yeniden yükle: Sunucu ayarları",
|
||||||
|
actionReloadTlsCertificates: "Yeniden yükle: TLS sertifikaları",
|
||||||
|
actionReloadLookupStores: "Yeniden yükle: Arama (lookup) depoları",
|
||||||
|
actionReloadBlockedIps: "Yeniden yükle: Engellenen IP listesi",
|
||||||
|
actionUpdateApps: "Uygulama Yönetimi: Uygulamaları güncelle",
|
||||||
|
actionTroubleshootDmarc: "DMARC: Sorun giderme",
|
||||||
|
actionClassifySpam: "Spam Filtresi: Bir iletiyi sınıflandır",
|
||||||
|
actionInvalidateCaches: "Önbellek: Tüm önbellekleri geçersiz kıl",
|
||||||
|
actionInvalidateNegativeCaches: "Önbellek: Negatif önbellekleri geçersiz kıl",
|
||||||
|
actionPauseMtaQueue: "MTA: Kuyruk işlemeyi duraklat",
|
||||||
|
actionResumeMtaQueue: "MTA: Kuyruk işlemeyi sürdür",
|
||||||
|
sysActionGet: "Eylemleri getir",
|
||||||
|
sysActionCreate: "Eylemleri oluştur",
|
||||||
|
sysActionUpdate: "Eylemleri güncelle",
|
||||||
|
sysActionDestroy: "Eylemleri sil",
|
||||||
|
sysActionQuery: "Eylemleri sorgula",
|
||||||
|
sysAddressBookGet: "Address book settings getir",
|
||||||
|
sysAddressBookUpdate: "Address book settings güncelle",
|
||||||
|
sysAiModelGet: "Yapay zeka modellerini getir",
|
||||||
|
sysAiModelCreate: "Yapay zeka modellerini oluştur",
|
||||||
|
sysAiModelUpdate: "Yapay zeka modellerini güncelle",
|
||||||
|
sysAiModelDestroy: "Yapay zeka modellerini sil",
|
||||||
|
sysAiModelQuery: "Yapay zeka modellerini sorgula",
|
||||||
|
sysAlertGet: "Uyarıları getir",
|
||||||
|
sysAlertCreate: "Uyarıları oluştur",
|
||||||
|
sysAlertUpdate: "Uyarıları güncelle",
|
||||||
|
sysAlertDestroy: "Uyarıları sil",
|
||||||
|
sysAlertQuery: "Uyarıları sorgula",
|
||||||
|
sysAllowedIpGet: "Adresleri getir",
|
||||||
|
sysAllowedIpCreate: "Adresleri oluştur",
|
||||||
|
sysAllowedIpUpdate: "Adresleri güncelle",
|
||||||
|
sysAllowedIpDestroy: "Adresleri sil",
|
||||||
|
sysAllowedIpQuery: "Adresleri sorgula",
|
||||||
|
sysApiKeyGet: "Api anahtarlarını getir",
|
||||||
|
sysApiKeyCreate: "Api anahtarlarını oluştur",
|
||||||
|
sysApiKeyUpdate: "Api anahtarlarını güncelle",
|
||||||
|
sysApiKeyDestroy: "Api anahtarlarını sil",
|
||||||
|
sysApiKeyQuery: "Api anahtarlarını sorgula",
|
||||||
|
sysAppPasswordGet: "Uygulama şifrelerini getir",
|
||||||
|
sysAppPasswordCreate: "Uygulama şifrelerini oluştur",
|
||||||
|
sysAppPasswordUpdate: "Uygulama şifrelerini güncelle",
|
||||||
|
sysAppPasswordDestroy: "Uygulama şifrelerini sil",
|
||||||
|
sysAppPasswordQuery: "Uygulama şifrelerini sorgula",
|
||||||
|
sysApplicationGet: "Uygulamaları getir",
|
||||||
|
sysApplicationCreate: "Uygulamaları oluştur",
|
||||||
|
sysApplicationUpdate: "Uygulamaları güncelle",
|
||||||
|
sysApplicationDestroy: "Uygulamaları sil",
|
||||||
|
sysApplicationQuery: "Uygulamaları sorgula",
|
||||||
|
sysArchivedItemGet: "Arşivlenmiş ögeleri getir",
|
||||||
|
sysArchivedItemCreate: "Arşivlenmiş ögeleri oluştur",
|
||||||
|
sysArchivedItemUpdate: "Arşivlenmiş ögeleri güncelle",
|
||||||
|
sysArchivedItemDestroy: "Arşivlenmiş ögeleri sil",
|
||||||
|
sysArchivedItemQuery: "Arşivlenmiş ögeleri sorgula",
|
||||||
|
sysArfExternalReportGet: "Raporları getir",
|
||||||
|
sysArfExternalReportCreate: "Raporları oluştur",
|
||||||
|
sysArfExternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysArfExternalReportDestroy: "Raporları sil",
|
||||||
|
sysArfExternalReportQuery: "Raporları sorgula",
|
||||||
|
sysAsnGet: "Asn settings getir",
|
||||||
|
sysAsnUpdate: "Asn settings güncelle",
|
||||||
|
sysAuthenticationGet: "Authentication settings getir",
|
||||||
|
sysAuthenticationUpdate: "Authentication settings güncelle",
|
||||||
|
sysBlobStoreGet: "Blob store settings getir",
|
||||||
|
sysBlobStoreUpdate: "Blob store settings güncelle",
|
||||||
|
sysBlockedIpGet: "Adresleri getir",
|
||||||
|
sysBlockedIpCreate: "Adresleri oluştur",
|
||||||
|
sysBlockedIpUpdate: "Adresleri güncelle",
|
||||||
|
sysBlockedIpDestroy: "Adresleri sil",
|
||||||
|
sysBlockedIpQuery: "Adresleri sorgula",
|
||||||
|
sysBootstrapGet: "Bootstrap settings getir",
|
||||||
|
sysBootstrapUpdate: "Bootstrap settings güncelle",
|
||||||
|
sysCacheGet: "Cache settings getir",
|
||||||
|
sysCacheUpdate: "Cache settings güncelle",
|
||||||
|
sysCalendarGet: "Calendar settings getir",
|
||||||
|
sysCalendarUpdate: "Calendar settings güncelle",
|
||||||
|
sysCalendarAlarmGet: "Calendar alarm settings getir",
|
||||||
|
sysCalendarAlarmUpdate: "Calendar alarm settings güncelle",
|
||||||
|
sysCalendarSchedulingGet: "Calendar scheduling settings getir",
|
||||||
|
sysCalendarSchedulingUpdate: "Calendar scheduling settings güncelle",
|
||||||
|
sysCertificateGet: "Sertifikaları getir",
|
||||||
|
sysCertificateCreate: "Sertifikaları oluştur",
|
||||||
|
sysCertificateUpdate: "Sertifikaları güncelle",
|
||||||
|
sysCertificateDestroy: "Sertifikaları sil",
|
||||||
|
sysCertificateQuery: "Sertifikaları sorgula",
|
||||||
|
sysClusterNodeGet: "Küme düğümlerini getir",
|
||||||
|
sysClusterNodeCreate: "Küme düğümlerini oluştur",
|
||||||
|
sysClusterNodeUpdate: "Küme düğümlerini güncelle",
|
||||||
|
sysClusterNodeDestroy: "Küme düğümlerini sil",
|
||||||
|
sysClusterNodeQuery: "Küme düğümlerini sorgula",
|
||||||
|
sysClusterRoleGet: "Rolleri getir",
|
||||||
|
sysClusterRoleCreate: "Rolleri oluştur",
|
||||||
|
sysClusterRoleUpdate: "Rolleri güncelle",
|
||||||
|
sysClusterRoleDestroy: "Rolleri sil",
|
||||||
|
sysClusterRoleQuery: "Rolleri sorgula",
|
||||||
|
sysCoordinatorGet: "Coordinator settings getir",
|
||||||
|
sysCoordinatorUpdate: "Coordinator settings güncelle",
|
||||||
|
sysDataRetentionGet: "Data retention settings getir",
|
||||||
|
sysDataRetentionUpdate: "Data retention settings güncelle",
|
||||||
|
sysDataStoreGet: "Data store settings getir",
|
||||||
|
sysDataStoreUpdate: "Data store settings güncelle",
|
||||||
|
sysDirectoryGet: "Dizinleri getir",
|
||||||
|
sysDirectoryCreate: "Dizinleri oluştur",
|
||||||
|
sysDirectoryUpdate: "Dizinleri güncelle",
|
||||||
|
sysDirectoryDestroy: "Dizinleri sil",
|
||||||
|
sysDirectoryQuery: "Dizinleri sorgula",
|
||||||
|
sysDkimReportSettingsGet: "Dkim report settings settings getir",
|
||||||
|
sysDkimReportSettingsUpdate: "Dkim report settings settings güncelle",
|
||||||
|
sysDkimSignatureGet: "Imzaları getir",
|
||||||
|
sysDkimSignatureCreate: "Imzaları oluştur",
|
||||||
|
sysDkimSignatureUpdate: "Imzaları güncelle",
|
||||||
|
sysDkimSignatureDestroy: "Imzaları sil",
|
||||||
|
sysDkimSignatureQuery: "Imzaları sorgula",
|
||||||
|
sysDmarcExternalReportGet: "Raporları getir",
|
||||||
|
sysDmarcExternalReportCreate: "Raporları oluştur",
|
||||||
|
sysDmarcExternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysDmarcExternalReportDestroy: "Raporları sil",
|
||||||
|
sysDmarcExternalReportQuery: "Raporları sorgula",
|
||||||
|
sysDmarcInternalReportGet: "Raporları getir",
|
||||||
|
sysDmarcInternalReportCreate: "Raporları oluştur",
|
||||||
|
sysDmarcInternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysDmarcInternalReportDestroy: "Raporları sil",
|
||||||
|
sysDmarcInternalReportQuery: "Raporları sorgula",
|
||||||
|
sysDmarcReportSettingsGet: "Dmarc report settings settings getir",
|
||||||
|
sysDmarcReportSettingsUpdate: "Dmarc report settings settings güncelle",
|
||||||
|
sysDnsResolverGet: "Dns resolver settings getir",
|
||||||
|
sysDnsResolverUpdate: "Dns resolver settings güncelle",
|
||||||
|
sysDnsServerGet: "Dns sunucularını getir",
|
||||||
|
sysDnsServerCreate: "Dns sunucularını oluştur",
|
||||||
|
sysDnsServerUpdate: "Dns sunucularını güncelle",
|
||||||
|
sysDnsServerDestroy: "Dns sunucularını sil",
|
||||||
|
sysDnsServerQuery: "Dns sunucularını sorgula",
|
||||||
|
sysDomainGet: "Alan adlarını getir",
|
||||||
|
sysDomainCreate: "Alan adlarını oluştur",
|
||||||
|
sysDomainUpdate: "Alan adlarını güncelle",
|
||||||
|
sysDomainDestroy: "Alan adlarını sil",
|
||||||
|
sysDomainQuery: "Alan adlarını sorgula",
|
||||||
|
sysDsnReportSettingsGet: "Dsn report settings settings getir",
|
||||||
|
sysDsnReportSettingsUpdate: "Dsn report settings settings güncelle",
|
||||||
|
sysEmailGet: "Email settings getir",
|
||||||
|
sysEmailUpdate: "Email settings güncelle",
|
||||||
|
sysEnterpriseGet: "Enterprise settings getir",
|
||||||
|
sysEnterpriseUpdate: "Enterprise settings güncelle",
|
||||||
|
sysEventTracingLevelGet: "Etkinlikleri getir",
|
||||||
|
sysEventTracingLevelCreate: "Etkinlikleri oluştur",
|
||||||
|
sysEventTracingLevelUpdate: "Etkinlikleri güncelle",
|
||||||
|
sysEventTracingLevelDestroy: "Etkinlikleri sil",
|
||||||
|
sysEventTracingLevelQuery: "Etkinlikleri sorgula",
|
||||||
|
sysFileStorageGet: "File storage settings getir",
|
||||||
|
sysFileStorageUpdate: "File storage settings güncelle",
|
||||||
|
sysHttpGet: "Http settings getir",
|
||||||
|
sysHttpUpdate: "Http settings güncelle",
|
||||||
|
sysHttpFormGet: "Http form settings getir",
|
||||||
|
sysHttpFormUpdate: "Http form settings güncelle",
|
||||||
|
sysHttpLookupGet: "Listeleri getir",
|
||||||
|
sysHttpLookupCreate: "Listeleri oluştur",
|
||||||
|
sysHttpLookupUpdate: "Listeleri güncelle",
|
||||||
|
sysHttpLookupDestroy: "Listeleri sil",
|
||||||
|
sysHttpLookupQuery: "Listeleri sorgula",
|
||||||
|
sysImapGet: "Imap settings getir",
|
||||||
|
sysImapUpdate: "Imap settings güncelle",
|
||||||
|
sysInMemoryStoreGet: "In memory store settings getir",
|
||||||
|
sysInMemoryStoreUpdate: "In memory store settings güncelle",
|
||||||
|
sysJmapGet: "Jmap settings getir",
|
||||||
|
sysJmapUpdate: "Jmap settings güncelle",
|
||||||
|
sysLogGet: "Günlük kayıtlarını getir",
|
||||||
|
sysLogCreate: "Günlük kayıtlarını oluştur",
|
||||||
|
sysLogUpdate: "Günlük kayıtlarını güncelle",
|
||||||
|
sysLogDestroy: "Günlük kayıtlarını sil",
|
||||||
|
sysLogQuery: "Günlük kayıtlarını sorgula",
|
||||||
|
sysMailingListGet: "E-posta listelerini getir",
|
||||||
|
sysMailingListCreate: "E-posta listelerini oluştur",
|
||||||
|
sysMailingListUpdate: "E-posta listelerini güncelle",
|
||||||
|
sysMailingListDestroy: "E-posta listelerini sil",
|
||||||
|
sysMailingListQuery: "E-posta listelerini sorgula",
|
||||||
|
sysMaskedEmailGet: "Maskelenmiş e-postaları getir",
|
||||||
|
sysMaskedEmailCreate: "Maskelenmiş e-postaları oluştur",
|
||||||
|
sysMaskedEmailUpdate: "Maskelenmiş e-postaları güncelle",
|
||||||
|
sysMaskedEmailDestroy: "Maskelenmiş e-postaları sil",
|
||||||
|
sysMaskedEmailQuery: "Maskelenmiş e-postaları sorgula",
|
||||||
|
sysMemoryLookupKeyGet: "Anahtarları getir",
|
||||||
|
sysMemoryLookupKeyCreate: "Anahtarları oluştur",
|
||||||
|
sysMemoryLookupKeyUpdate: "Anahtarları güncelle",
|
||||||
|
sysMemoryLookupKeyDestroy: "Anahtarları sil",
|
||||||
|
sysMemoryLookupKeyQuery: "Anahtarları sorgula",
|
||||||
|
sysMemoryLookupKeyValueGet: "Anahtarları getir",
|
||||||
|
sysMemoryLookupKeyValueCreate: "Anahtarları oluştur",
|
||||||
|
sysMemoryLookupKeyValueUpdate: "Anahtarları güncelle",
|
||||||
|
sysMemoryLookupKeyValueDestroy: "Anahtarları sil",
|
||||||
|
sysMemoryLookupKeyValueQuery: "Anahtarları sorgula",
|
||||||
|
sysMetricGet: "Metrikleri getir",
|
||||||
|
sysMetricCreate: "Metrikleri oluştur",
|
||||||
|
sysMetricUpdate: "Metrikleri güncelle",
|
||||||
|
sysMetricDestroy: "Metrikleri sil",
|
||||||
|
sysMetricQuery: "Metrikleri sorgula",
|
||||||
|
sysMetricsGet: "Metrics settings getir",
|
||||||
|
sysMetricsUpdate: "Metrics settings güncelle",
|
||||||
|
sysMetricsStoreGet: "Metrics store settings getir",
|
||||||
|
sysMetricsStoreUpdate: "Metrics store settings güncelle",
|
||||||
|
sysMtaConnectionStrategyGet: "Stratejileri getir",
|
||||||
|
sysMtaConnectionStrategyCreate: "Stratejileri oluştur",
|
||||||
|
sysMtaConnectionStrategyUpdate: "Stratejileri güncelle",
|
||||||
|
sysMtaConnectionStrategyDestroy: "Stratejileri sil",
|
||||||
|
sysMtaConnectionStrategyQuery: "Stratejileri sorgula",
|
||||||
|
sysMtaDeliveryScheduleGet: "Zamanlamaları getir",
|
||||||
|
sysMtaDeliveryScheduleCreate: "Zamanlamaları oluştur",
|
||||||
|
sysMtaDeliveryScheduleUpdate: "Zamanlamaları güncelle",
|
||||||
|
sysMtaDeliveryScheduleDestroy: "Zamanlamaları sil",
|
||||||
|
sysMtaDeliveryScheduleQuery: "Zamanlamaları sorgula",
|
||||||
|
sysMtaExtensionsGet: "Mta extensions settings getir",
|
||||||
|
sysMtaExtensionsUpdate: "Mta extensions settings güncelle",
|
||||||
|
sysMtaHookGet: "Kancaları (hook) getir",
|
||||||
|
sysMtaHookCreate: "Kancaları (hook) oluştur",
|
||||||
|
sysMtaHookUpdate: "Kancaları (hook) güncelle",
|
||||||
|
sysMtaHookDestroy: "Kancaları (hook) sil",
|
||||||
|
sysMtaHookQuery: "Kancaları (hook) sorgula",
|
||||||
|
sysMtaInboundSessionGet: "Mta inbound session settings getir",
|
||||||
|
sysMtaInboundSessionUpdate: "Mta inbound session settings güncelle",
|
||||||
|
sysMtaInboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||||
|
sysMtaInboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||||
|
sysMtaInboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||||
|
sysMtaInboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||||
|
sysMtaInboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||||
|
sysMtaMilterGet: "Milter'ları getir",
|
||||||
|
sysMtaMilterCreate: "Milter'ları oluştur",
|
||||||
|
sysMtaMilterUpdate: "Milter'ları güncelle",
|
||||||
|
sysMtaMilterDestroy: "Milter'ları sil",
|
||||||
|
sysMtaMilterQuery: "Milter'ları sorgula",
|
||||||
|
sysMtaOutboundStrategyGet: "Mta outbound strategy settings getir",
|
||||||
|
sysMtaOutboundStrategyUpdate: "Mta outbound strategy settings güncelle",
|
||||||
|
sysMtaOutboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||||
|
sysMtaOutboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||||
|
sysMtaOutboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||||
|
sysMtaOutboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||||
|
sysMtaOutboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||||
|
sysMtaQueueQuotaGet: "Kotaları getir",
|
||||||
|
sysMtaQueueQuotaCreate: "Kotaları oluştur",
|
||||||
|
sysMtaQueueQuotaUpdate: "Kotaları güncelle",
|
||||||
|
sysMtaQueueQuotaDestroy: "Kotaları sil",
|
||||||
|
sysMtaQueueQuotaQuery: "Kotaları sorgula",
|
||||||
|
sysMtaRouteGet: "Rotaları getir",
|
||||||
|
sysMtaRouteCreate: "Rotaları oluştur",
|
||||||
|
sysMtaRouteUpdate: "Rotaları güncelle",
|
||||||
|
sysMtaRouteDestroy: "Rotaları sil",
|
||||||
|
sysMtaRouteQuery: "Rotaları sorgula",
|
||||||
|
sysMtaStageAuthGet: "Mta stage auth settings getir",
|
||||||
|
sysMtaStageAuthUpdate: "Mta stage auth settings güncelle",
|
||||||
|
sysMtaStageConnectGet: "Mta stage connect settings getir",
|
||||||
|
sysMtaStageConnectUpdate: "Mta stage connect settings güncelle",
|
||||||
|
sysMtaStageDataGet: "Mta stage data settings getir",
|
||||||
|
sysMtaStageDataUpdate: "Mta stage data settings güncelle",
|
||||||
|
sysMtaStageEhloGet: "Mta stage ehlo settings getir",
|
||||||
|
sysMtaStageEhloUpdate: "Mta stage ehlo settings güncelle",
|
||||||
|
sysMtaStageMailGet: "Mta stage mail settings getir",
|
||||||
|
sysMtaStageMailUpdate: "Mta stage mail settings güncelle",
|
||||||
|
sysMtaStageRcptGet: "Mta stage rcpt settings getir",
|
||||||
|
sysMtaStageRcptUpdate: "Mta stage rcpt settings güncelle",
|
||||||
|
sysMtaStsGet: "Mta sts settings getir",
|
||||||
|
sysMtaStsUpdate: "Mta sts settings güncelle",
|
||||||
|
sysMtaTlsStrategyGet: "Stratejileri getir",
|
||||||
|
sysMtaTlsStrategyCreate: "Stratejileri oluştur",
|
||||||
|
sysMtaTlsStrategyUpdate: "Stratejileri güncelle",
|
||||||
|
sysMtaTlsStrategyDestroy: "Stratejileri sil",
|
||||||
|
sysMtaTlsStrategyQuery: "Stratejileri sorgula",
|
||||||
|
sysMtaVirtualQueueGet: "Kuyrukları getir",
|
||||||
|
sysMtaVirtualQueueCreate: "Kuyrukları oluştur",
|
||||||
|
sysMtaVirtualQueueUpdate: "Kuyrukları güncelle",
|
||||||
|
sysMtaVirtualQueueDestroy: "Kuyrukları sil",
|
||||||
|
sysMtaVirtualQueueQuery: "Kuyrukları sorgula",
|
||||||
|
sysNetworkListenerGet: "Dinleyicileri (listener) getir",
|
||||||
|
sysNetworkListenerCreate: "Dinleyicileri (listener) oluştur",
|
||||||
|
sysNetworkListenerUpdate: "Dinleyicileri (listener) güncelle",
|
||||||
|
sysNetworkListenerDestroy: "Dinleyicileri (listener) sil",
|
||||||
|
sysNetworkListenerQuery: "Dinleyicileri (listener) sorgula",
|
||||||
|
sysOAuthClientGet: "Oauth istemcilerini getir",
|
||||||
|
sysOAuthClientCreate: "Oauth istemcilerini oluştur",
|
||||||
|
sysOAuthClientUpdate: "Oauth istemcilerini güncelle",
|
||||||
|
sysOAuthClientDestroy: "Oauth istemcilerini sil",
|
||||||
|
sysOAuthClientQuery: "Oauth istemcilerini sorgula",
|
||||||
|
sysOidcProviderGet: "Oidc provider settings getir",
|
||||||
|
sysOidcProviderUpdate: "Oidc provider settings güncelle",
|
||||||
|
sysPublicKeyGet: "Genel anahtarları getir",
|
||||||
|
sysPublicKeyCreate: "Genel anahtarları oluştur",
|
||||||
|
sysPublicKeyUpdate: "Genel anahtarları güncelle",
|
||||||
|
sysPublicKeyDestroy: "Genel anahtarları sil",
|
||||||
|
sysPublicKeyQuery: "Genel anahtarları sorgula",
|
||||||
|
sysQueuedMessageGet: "Iletileri getir",
|
||||||
|
sysQueuedMessageCreate: "Iletileri oluştur",
|
||||||
|
sysQueuedMessageUpdate: "Iletileri güncelle",
|
||||||
|
sysQueuedMessageDestroy: "Iletileri sil",
|
||||||
|
sysQueuedMessageQuery: "Iletileri sorgula",
|
||||||
|
sysReportSettingsGet: "Report settings settings getir",
|
||||||
|
sysReportSettingsUpdate: "Report settings settings güncelle",
|
||||||
|
sysRoleGet: "Rolleri getir",
|
||||||
|
sysRoleCreate: "Rolleri oluştur",
|
||||||
|
sysRoleUpdate: "Rolleri güncelle",
|
||||||
|
sysRoleDestroy: "Rolleri sil",
|
||||||
|
sysRoleQuery: "Rolleri sorgula",
|
||||||
|
sysSearchGet: "Search settings getir",
|
||||||
|
sysSearchUpdate: "Search settings güncelle",
|
||||||
|
sysSearchStoreGet: "Search store settings getir",
|
||||||
|
sysSearchStoreUpdate: "Search store settings güncelle",
|
||||||
|
sysSecurityGet: "Security settings getir",
|
||||||
|
sysSecurityUpdate: "Security settings güncelle",
|
||||||
|
sysSenderAuthGet: "Sender auth settings getir",
|
||||||
|
sysSenderAuthUpdate: "Sender auth settings güncelle",
|
||||||
|
sysSharingGet: "Sharing settings getir",
|
||||||
|
sysSharingUpdate: "Sharing settings güncelle",
|
||||||
|
sysSieveSystemInterpreterGet: "Sieve system interpreter settings getir",
|
||||||
|
sysSieveSystemInterpreterUpdate: "Sieve system interpreter settings güncelle",
|
||||||
|
sysSieveSystemScriptGet: "Betikleri getir",
|
||||||
|
sysSieveSystemScriptCreate: "Betikleri oluştur",
|
||||||
|
sysSieveSystemScriptUpdate: "Betikleri güncelle",
|
||||||
|
sysSieveSystemScriptDestroy: "Betikleri sil",
|
||||||
|
sysSieveSystemScriptQuery: "Betikleri sorgula",
|
||||||
|
sysSieveUserInterpreterGet: "Sieve user interpreter settings getir",
|
||||||
|
sysSieveUserInterpreterUpdate: "Sieve user interpreter settings güncelle",
|
||||||
|
sysSieveUserScriptGet: "Betikleri getir",
|
||||||
|
sysSieveUserScriptCreate: "Betikleri oluştur",
|
||||||
|
sysSieveUserScriptUpdate: "Betikleri güncelle",
|
||||||
|
sysSieveUserScriptDestroy: "Betikleri sil",
|
||||||
|
sysSieveUserScriptQuery: "Betikleri sorgula",
|
||||||
|
sysSpamClassifierGet: "Spam classifier settings getir",
|
||||||
|
sysSpamClassifierUpdate: "Spam classifier settings güncelle",
|
||||||
|
sysSpamDnsblServerGet: "Sunucuları getir",
|
||||||
|
sysSpamDnsblServerCreate: "Sunucuları oluştur",
|
||||||
|
sysSpamDnsblServerUpdate: "Sunucuları güncelle",
|
||||||
|
sysSpamDnsblServerDestroy: "Sunucuları sil",
|
||||||
|
sysSpamDnsblServerQuery: "Sunucuları sorgula",
|
||||||
|
sysSpamDnsblSettingsGet: "Spam dnsbl settings settings getir",
|
||||||
|
sysSpamDnsblSettingsUpdate: "Spam dnsbl settings settings güncelle",
|
||||||
|
sysSpamFileExtensionGet: "Uzantıları getir",
|
||||||
|
sysSpamFileExtensionCreate: "Uzantıları oluştur",
|
||||||
|
sysSpamFileExtensionUpdate: "Uzantıları güncelle",
|
||||||
|
sysSpamFileExtensionDestroy: "Uzantıları sil",
|
||||||
|
sysSpamFileExtensionQuery: "Uzantıları sorgula",
|
||||||
|
sysSpamLlmGet: "Spam llm settings getir",
|
||||||
|
sysSpamLlmUpdate: "Spam llm settings güncelle",
|
||||||
|
sysSpamPyzorGet: "Spam pyzor settings getir",
|
||||||
|
sysSpamPyzorUpdate: "Spam pyzor settings güncelle",
|
||||||
|
sysSpamRuleGet: "Kuralları getir",
|
||||||
|
sysSpamRuleCreate: "Kuralları oluştur",
|
||||||
|
sysSpamRuleUpdate: "Kuralları güncelle",
|
||||||
|
sysSpamRuleDestroy: "Kuralları sil",
|
||||||
|
sysSpamRuleQuery: "Kuralları sorgula",
|
||||||
|
sysSpamSettingsGet: "Spam settings settings getir",
|
||||||
|
sysSpamSettingsUpdate: "Spam settings settings güncelle",
|
||||||
|
sysSpamTagGet: "Etiketleri getir",
|
||||||
|
sysSpamTagCreate: "Etiketleri oluştur",
|
||||||
|
sysSpamTagUpdate: "Etiketleri güncelle",
|
||||||
|
sysSpamTagDestroy: "Etiketleri sil",
|
||||||
|
sysSpamTagQuery: "Etiketleri sorgula",
|
||||||
|
sysSpamTrainingSampleGet: "Örnekleri getir",
|
||||||
|
sysSpamTrainingSampleCreate: "Örnekleri oluştur",
|
||||||
|
sysSpamTrainingSampleUpdate: "Örnekleri güncelle",
|
||||||
|
sysSpamTrainingSampleDestroy: "Örnekleri sil",
|
||||||
|
sysSpamTrainingSampleQuery: "Örnekleri sorgula",
|
||||||
|
sysSpfReportSettingsGet: "Spf report settings settings getir",
|
||||||
|
sysSpfReportSettingsUpdate: "Spf report settings settings güncelle",
|
||||||
|
sysStoreLookupGet: "Aramaları (lookup) getir",
|
||||||
|
sysStoreLookupCreate: "Aramaları (lookup) oluştur",
|
||||||
|
sysStoreLookupUpdate: "Aramaları (lookup) güncelle",
|
||||||
|
sysStoreLookupDestroy: "Aramaları (lookup) sil",
|
||||||
|
sysStoreLookupQuery: "Aramaları (lookup) sorgula",
|
||||||
|
sysSystemSettingsGet: "System settings settings getir",
|
||||||
|
sysSystemSettingsUpdate: "System settings settings güncelle",
|
||||||
|
taskIndexDocument: "Belgeyi dizine ekle",
|
||||||
|
taskUnindexDocument: "Belgeyi dizinden çıkar",
|
||||||
|
taskIndexTrace: "Telemetri izini (trace) dizine ekle",
|
||||||
|
taskCalendarAlarmEmail: "Takvim anımsatıcı e-postası",
|
||||||
|
taskCalendarAlarmNotification: "Takvim anımsatıcı bildirimi",
|
||||||
|
taskCalendarItipMessage: "Takvim iTIP iletisi",
|
||||||
|
taskMergeThreads: "E-posta yazışma dizilerini birleştir",
|
||||||
|
taskDmarcReport: "Uzak sunucuya DMARC raporu gönder",
|
||||||
|
taskTlsReport: "Uzak sunucuya TLS raporu gönder",
|
||||||
|
taskRestoreArchivedItem: "Arşivlenmiş ögeyi geri yükle",
|
||||||
|
taskDestroyAccount: "Account and all associated data sil",
|
||||||
|
taskAccountMaintenance: "account maintenance operations işlemini gerçekleştir",
|
||||||
|
taskTenantMaintenance: "tenant maintenance operations işlemini gerçekleştir",
|
||||||
|
taskStoreMaintenance: "store maintenance operations işlemini gerçekleştir",
|
||||||
|
taskSpamFilterMaintenance: "spam filter maintenance operations işlemini gerçekleştir",
|
||||||
|
taskAcmeRenewal: "ACME certificate renewal for a domain işlemini gerçekleştir",
|
||||||
|
taskDkimManagement: "DKIM key rotation for a domain işlemini gerçekleştir",
|
||||||
|
taskDnsManagement: "DNS management for a domain işlemini gerçekleştir",
|
||||||
|
sysTaskGet: "Görevleri getir",
|
||||||
|
sysTaskCreate: "Görevleri oluştur",
|
||||||
|
sysTaskUpdate: "Görevleri güncelle",
|
||||||
|
sysTaskDestroy: "Görevleri sil",
|
||||||
|
sysTaskQuery: "Görevleri sorgula",
|
||||||
|
sysTaskManagerGet: "Task manager settings getir",
|
||||||
|
sysTaskManagerUpdate: "Task manager settings güncelle",
|
||||||
|
sysTenantGet: "Kiracıları (tenant) getir",
|
||||||
|
sysTenantCreate: "Kiracıları (tenant) oluştur",
|
||||||
|
sysTenantUpdate: "Kiracıları (tenant) güncelle",
|
||||||
|
sysTenantDestroy: "Kiracıları (tenant) sil",
|
||||||
|
sysTenantQuery: "Kiracıları (tenant) sorgula",
|
||||||
|
sysTlsExternalReportGet: "Raporları getir",
|
||||||
|
sysTlsExternalReportCreate: "Raporları oluştur",
|
||||||
|
sysTlsExternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysTlsExternalReportDestroy: "Raporları sil",
|
||||||
|
sysTlsExternalReportQuery: "Raporları sorgula",
|
||||||
|
sysTlsInternalReportGet: "Raporları getir",
|
||||||
|
sysTlsInternalReportCreate: "Raporları oluştur",
|
||||||
|
sysTlsInternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysTlsInternalReportDestroy: "Raporları sil",
|
||||||
|
sysTlsInternalReportQuery: "Raporları sorgula",
|
||||||
|
sysTlsReportSettingsGet: "Tls report settings settings getir",
|
||||||
|
sysTlsReportSettingsUpdate: "Tls report settings settings güncelle",
|
||||||
|
sysTraceGet: "Izleme kayıtlarını (trace) getir",
|
||||||
|
sysTraceCreate: "Izleme kayıtlarını (trace) oluştur",
|
||||||
|
sysTraceUpdate: "Izleme kayıtlarını (trace) güncelle",
|
||||||
|
sysTraceDestroy: "Izleme kayıtlarını (trace) sil",
|
||||||
|
sysTraceQuery: "Izleme kayıtlarını (trace) sorgula",
|
||||||
|
sysTracerGet: "Izleyicileri (tracer) getir",
|
||||||
|
sysTracerCreate: "Izleyicileri (tracer) oluştur",
|
||||||
|
sysTracerUpdate: "Izleyicileri (tracer) güncelle",
|
||||||
|
sysTracerDestroy: "Izleyicileri (tracer) sil",
|
||||||
|
sysTracerQuery: "Izleyicileri (tracer) sorgula",
|
||||||
|
sysTracingStoreGet: "Tracing store settings getir",
|
||||||
|
sysTracingStoreUpdate: "Tracing store settings güncelle",
|
||||||
|
sysWebDavGet: "Web dav settings getir",
|
||||||
|
sysWebDavUpdate: "Web dav settings güncelle",
|
||||||
|
sysWebHookGet: "Webhook'ları getir",
|
||||||
|
sysWebHookCreate: "Webhook'ları oluştur",
|
||||||
|
sysWebHookUpdate: "Webhook'ları güncelle",
|
||||||
|
sysWebHookDestroy: "Webhook'ları sil",
|
||||||
|
sysWebHookQuery: "Webhook'ları sorgula",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Tamanho do texto",
|
"Text size": "Tamanho do texto",
|
||||||
"Font size": "Tamanho da fonte",
|
"Font size": "Tamanho da fonte",
|
||||||
"Small": "Pequeno",
|
"Small": "Pequeno",
|
||||||
|
"Original size": "Tamanho original",
|
||||||
|
"Drag to resize": "Arraste para redimensionar",
|
||||||
|
"Image size": "Tamanho da imagem",
|
||||||
"Medium": "Médio",
|
"Medium": "Médio",
|
||||||
"Large": "Grande",
|
"Large": "Grande",
|
||||||
"Huge": "Muito grande",
|
"Huge": "Muito grande",
|
||||||
|
|||||||
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Размер текста",
|
"Text size": "Размер текста",
|
||||||
"Font size": "Размер шрифта",
|
"Font size": "Размер шрифта",
|
||||||
"Small": "Мелкий",
|
"Small": "Мелкий",
|
||||||
|
"Original size": "Исходный размер",
|
||||||
|
"Drag to resize": "Перетащите, чтобы изменить размер",
|
||||||
|
"Image size": "Размер изображения",
|
||||||
"Medium": "Средний",
|
"Medium": "Средний",
|
||||||
"Large": "Крупный",
|
"Large": "Крупный",
|
||||||
"Huge": "Очень крупный",
|
"Huge": "Очень крупный",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -805,6 +805,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Розмір тексту",
|
"Text size": "Розмір тексту",
|
||||||
"Font size": "Розмір шрифту",
|
"Font size": "Розмір шрифту",
|
||||||
"Small": "Дрібний",
|
"Small": "Дрібний",
|
||||||
|
"Original size": "Початковий розмір",
|
||||||
|
"Drag to resize": "Перетягніть, щоб змінити розмір",
|
||||||
|
"Image size": "Розмір зображення",
|
||||||
"Medium": "Середній",
|
"Medium": "Середній",
|
||||||
"Large": "Великий",
|
"Large": "Великий",
|
||||||
"Huge": "Дуже великий",
|
"Huge": "Дуже великий",
|
||||||
|
|||||||
@@ -807,6 +807,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "文字大小",
|
"Text size": "文字大小",
|
||||||
"Font size": "字号",
|
"Font size": "字号",
|
||||||
"Small": "小",
|
"Small": "小",
|
||||||
|
"Original size": "原始大小",
|
||||||
|
"Drag to resize": "拖动以调整大小",
|
||||||
|
"Image size": "图片大小",
|
||||||
"Medium": "中",
|
"Medium": "中",
|
||||||
"Large": "大",
|
"Large": "大",
|
||||||
"Huge": "特大",
|
"Huge": "特大",
|
||||||
|
|||||||
@@ -1475,6 +1475,11 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
|
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
|
||||||
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
|
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
/* Labels share the sender's line (#35). The sender gives way first, then the
|
||||||
|
labels, each of which truncates rather than pushing the date off the row. */
|
||||||
|
.mail-list.two-line .msg-row .msg-line1 .msg-from { flex: 0 1 auto; min-width: 3em; }
|
||||||
|
.mail-list.two-line .msg-row .msg-line1 .msg-labels { flex: 0 1 auto; min-width: 0; max-width: 55%; overflow: hidden; }
|
||||||
|
.mail-list.two-line .msg-row .msg-line1 .msg-labels .tag { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: block; line-height: 18px; }
|
||||||
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
|
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
|
||||||
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
|
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
|
||||||
.mail-list.two-line .msg-row .msg-preview { flex: 1; }
|
.mail-list.two-line .msg-row .msg-preview { flex: 1; }
|
||||||
@@ -1622,6 +1627,14 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
||||||
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
||||||
.editor-area img { max-width: 100%; height: auto; }
|
.editor-area img { max-width: 100%; height: auto; }
|
||||||
|
/* Image resizing (Gitea issue #26): drawn over the editor, never inside it, so none of it is sent. */
|
||||||
|
.img-resize-layer { position: absolute; pointer-events: none; overflow: hidden; z-index: 5; }
|
||||||
|
.img-resize-frame { position: absolute; outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||||
|
.img-resize-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; background: var(--accent); border: 2px solid var(--bg-elev); border-radius: 3px; cursor: nwse-resize; pointer-events: auto; touch-action: none; }
|
||||||
|
.img-resize-bar { position: absolute; display: flex; gap: 4px; padding: 3px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-2); pointer-events: auto; }
|
||||||
|
.img-resize-bar .btn-sm { height: 26px; padding: 0 8px; }
|
||||||
|
.img-resize-bar .btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
@media (pointer: coarse) { .img-resize-handle { width: 24px; height: 24px; right: -12px; bottom: -12px; border-radius: 50%; } }
|
||||||
.editor-area a { color: var(--link); }
|
.editor-area a { color: var(--link); }
|
||||||
.editor-area .ihm-signature { color: var(--fg-muted); }
|
.editor-area .ihm-signature { color: var(--fg-muted); }
|
||||||
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
|
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resizing an image in the composer (Gitea issue #26).
|
||||||
|
*
|
||||||
|
* An inserted image went in at its own size, capped at the editor's width,
|
||||||
|
* and nothing could change it. A screenshot pasted from a large monitor
|
||||||
|
* filled the message. Click an image and this puts a frame round it, with a
|
||||||
|
* handle in the corner to drag it to any width, and three fixed sizes plus
|
||||||
|
* Original size above it.
|
||||||
|
*
|
||||||
|
* The frame is drawn over the editor rather than inside it, so none of it
|
||||||
|
* ends up in the message. The only thing that changes in the message is the
|
||||||
|
* image's own width: as a `width` attribute, which Outlook needs because it
|
||||||
|
* ignores CSS on images, and as an inline style, which everything else
|
||||||
|
* reads. `max-width:100%` stays, so a large image still shrinks to fit a
|
||||||
|
* phone on the receiving end.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fixed widths, in CSS pixels, as offered in the report. */
|
||||||
|
export const IMAGE_SIZE_PRESETS: ReadonlyArray<{ label: string; width: number }> = [
|
||||||
|
{ label: "Small", width: 300 },
|
||||||
|
{ label: "Medium", width: 600 },
|
||||||
|
{ label: "Large", width: 900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_WIDTH = 24;
|
||||||
|
|
||||||
|
/** Set an image's width, or clear it with `null` to go back to its own size. */
|
||||||
|
export function setImageWidth(img: HTMLImageElement, width: number | null): void {
|
||||||
|
// A height would fight the new width and stretch the image.
|
||||||
|
img.removeAttribute("height");
|
||||||
|
img.style.height = "auto";
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
if (width === null) {
|
||||||
|
img.removeAttribute("width");
|
||||||
|
img.style.width = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = Math.max(MIN_WIDTH, Math.round(width));
|
||||||
|
img.setAttribute("width", String(w));
|
||||||
|
img.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The width the image has been given, or null when it is at its own size. */
|
||||||
|
export function imageWidth(img: HTMLImageElement): number | null {
|
||||||
|
const w = Number.parseInt(img.getAttribute("width") ?? "", 10);
|
||||||
|
return Number.isFinite(w) && w > 0 ? w : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
/** Where the editor area sits inside the composer, which is what the layer is positioned against. */
|
||||||
|
areaTop: number;
|
||||||
|
areaLeft: number;
|
||||||
|
areaWidth: number;
|
||||||
|
areaHeight: number;
|
||||||
|
/** The image, relative to the editor area's visible box. */
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The editable area. Its parent must be positioned, as `.composer-editor` is. */
|
||||||
|
area: HTMLDivElement;
|
||||||
|
img: HTMLImageElement;
|
||||||
|
/** Called once a change is finished, to save the draft. */
|
||||||
|
onChange: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageResizer({ area, img, onChange, onClose }: Props) {
|
||||||
|
const [box, setBox] = useState<Box | null>(null);
|
||||||
|
const [width, setWidth] = useState<number | null>(() => imageWidth(img));
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const drag = useRef<{ x: number; width: number; max: number } | null>(null);
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const a = area.getBoundingClientRect();
|
||||||
|
const r = img.getBoundingClientRect();
|
||||||
|
setBox({
|
||||||
|
areaTop: area.offsetTop, areaLeft: area.offsetLeft, areaWidth: a.width, areaHeight: a.height,
|
||||||
|
top: r.top - a.top, left: r.left - a.left, width: r.width, height: r.height,
|
||||||
|
});
|
||||||
|
}, [area, img]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(img);
|
||||||
|
ro.observe(area);
|
||||||
|
area.addEventListener("scroll", measure);
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
area.removeEventListener("scroll", measure);
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
};
|
||||||
|
}, [area, img, measure]);
|
||||||
|
|
||||||
|
// Anything but the image or the frame lets it go, and so does Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (target === img || layerRef.current?.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onDown, true);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onDown, true);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [img, onClose]);
|
||||||
|
|
||||||
|
const apply = (w: number | null) => {
|
||||||
|
setImageWidth(img, w);
|
||||||
|
setWidth(imageWidth(img));
|
||||||
|
measure();
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleDown = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
const style = getComputedStyle(area);
|
||||||
|
const max = area.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
|
||||||
|
drag.current = { x: e.clientX, width: img.getBoundingClientRect().width, max };
|
||||||
|
};
|
||||||
|
const onHandleMove = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||||
|
const d = drag.current;
|
||||||
|
if (!d) return;
|
||||||
|
const w = Math.min(d.max, Math.max(MIN_WIDTH, d.width + (e.clientX - d.x)));
|
||||||
|
setImageWidth(img, w);
|
||||||
|
measure();
|
||||||
|
};
|
||||||
|
const onHandleUp = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||||
|
if (!drag.current) return;
|
||||||
|
drag.current = null;
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
setWidth(imageWidth(img));
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!box) return null;
|
||||||
|
// The size bar sits above the image, or inside its top edge when the image
|
||||||
|
// starts at the top of what is visible.
|
||||||
|
const barTop = Math.max(4, box.top - 34);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={layerRef}
|
||||||
|
className="img-resize-layer"
|
||||||
|
style={{ top: box.areaTop, left: box.areaLeft, width: box.areaWidth, height: box.areaHeight }}
|
||||||
|
>
|
||||||
|
<div className="img-resize-frame" style={{ top: box.top, left: box.left, width: box.width, height: box.height }}>
|
||||||
|
<span
|
||||||
|
className="img-resize-handle"
|
||||||
|
role="slider"
|
||||||
|
aria-label={translate("Drag to resize")}
|
||||||
|
aria-valuenow={Math.round(box.width)}
|
||||||
|
aria-valuemin={MIN_WIDTH}
|
||||||
|
title={translate("Drag to resize")}
|
||||||
|
onPointerDown={onHandleDown}
|
||||||
|
onPointerMove={onHandleMove}
|
||||||
|
onPointerUp={onHandleUp}
|
||||||
|
onPointerCancel={onHandleUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="img-resize-bar" role="toolbar" aria-label={translate("Image size")} style={{ top: barTop, left: Math.max(4, box.left) }}>
|
||||||
|
{IMAGE_SIZE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.width}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
aria-pressed={width === p.width}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => apply(p.width)}
|
||||||
|
>
|
||||||
|
{translate(p.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm" aria-pressed={width === null} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(null)}>
|
||||||
|
{translate("Original size")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageI
|
|||||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||||
import { Popover, useMenu } from "@/ui/popover";
|
import { Popover, useMenu } from "@/ui/popover";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
import { ImageResizer } from "./ImageResizer";
|
||||||
|
|
||||||
export interface RichEditorHandle {
|
export interface RichEditorHandle {
|
||||||
focus(): void;
|
focus(): void;
|
||||||
@@ -37,6 +38,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
const linkMenu = useMenu();
|
const linkMenu = useMenu();
|
||||||
const [linkUrl, setLinkUrl] = useState("");
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
const savedRange = useRef<Range | null>(null);
|
const savedRange = useRef<Range | null>(null);
|
||||||
|
/** The image whose size is being changed, if one has been clicked. */
|
||||||
|
const [selectedImg, setSelectedImg] = useState<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// Sync external html → DOM (only when it differs from what we emitted)
|
// Sync external html → DOM (only when it differs from what we emitted)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +48,7 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
if (html !== lastEmitted.current) {
|
if (html !== lastEmitted.current) {
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
lastEmitted.current = html;
|
lastEmitted.current = html;
|
||||||
|
setSelectedImg(null);
|
||||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||||
}
|
}
|
||||||
}, [html]);
|
}, [html]);
|
||||||
@@ -72,6 +76,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const v = el.innerHTML;
|
const v = el.innerHTML;
|
||||||
lastEmitted.current = v;
|
lastEmitted.current = v;
|
||||||
|
// Deleted, or typed over: there is nothing left to resize.
|
||||||
|
setSelectedImg((img) => (img && el.contains(img) ? img : null));
|
||||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||||
onChange(v);
|
onChange(v);
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
@@ -205,6 +211,14 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
onMouseUp={saveRange}
|
onMouseUp={saveRange}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
onClick={(e) => setSelectedImg(e.target instanceof HTMLImageElement ? e.target : null)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// A right-click on an image offers its sizes, which is where the
|
||||||
|
// report went looking for them, instead of the browser's menu.
|
||||||
|
if (!(e.target instanceof HTMLImageElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedImg(e.target);
|
||||||
|
}}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||||
@@ -221,6 +235,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={translate("Message body")}
|
aria-label={translate("Message body")}
|
||||||
/>
|
/>
|
||||||
|
{selectedImg && elRef.current && (
|
||||||
|
<ImageResizer area={elRef.current} img={selectedImg} onChange={emit} onClose={() => setSelectedImg(null)} />
|
||||||
|
)}
|
||||||
{showToolbar && (
|
{showToolbar && (
|
||||||
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
|
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
|
||||||
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RichEditor } from "../RichEditor";
|
||||||
|
import { imageWidth, setImageWidth } from "../ImageResizer";
|
||||||
|
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An image in the composer could not be resized at all (Gitea issue #26).
|
||||||
|
* jsdom does no layout, so dragging the handle is checked in a browser; these
|
||||||
|
* pin what a size does to the message, and that the frame never ends up in it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("setting an image's width", () => {
|
||||||
|
it("writes both the attribute Outlook reads and the style everything else reads", () => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.setAttribute("height", "480");
|
||||||
|
setImageWidth(img, 300);
|
||||||
|
expect(img.getAttribute("width")).toBe("300");
|
||||||
|
expect(img.style.width).toBe("300px");
|
||||||
|
// A leftover height would stretch it out of shape.
|
||||||
|
expect(img.hasAttribute("height")).toBe(false);
|
||||||
|
expect(img.style.height).toBe("auto");
|
||||||
|
// Still shrinks to fit a narrow screen on the receiving end.
|
||||||
|
expect(img.style.maxWidth).toBe("100%");
|
||||||
|
expect(imageWidth(img)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes back to the image's own size", () => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
setImageWidth(img, 600);
|
||||||
|
setImageWidth(img, null);
|
||||||
|
expect(img.hasAttribute("width")).toBe(false);
|
||||||
|
expect(img.style.width).toBe("");
|
||||||
|
expect(imageWidth(img)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives the sanitizer a draft goes through", () => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = "https://example.com/a.png";
|
||||||
|
setImageWidth(img, 450);
|
||||||
|
const out = sanitizeEditorHtml(img.outerHTML);
|
||||||
|
expect(out).toContain('width="450"');
|
||||||
|
expect(out).toContain("width: 450px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resizing an image in the editor", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let onChange: ReturnType<typeof vi.fn<(html: string) => void>>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
onChange = vi.fn<(html: string) => void>();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RichEditor html='<p>Hi</p><img src="data:image/png;base64,AA==" alt="shot" style="max-width:100%">' onChange={onChange} showToolbar={false} />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||||
|
const img = () => editor().querySelector("img")!;
|
||||||
|
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>(".img-resize-bar button")].find((b) => b.textContent === label);
|
||||||
|
|
||||||
|
it("offers sizes when the image is clicked, and applies one", async () => {
|
||||||
|
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||||
|
await act(async () => img().click());
|
||||||
|
expect(button("Medium")).toBeTruthy();
|
||||||
|
await act(async () => button("Medium")!.click());
|
||||||
|
expect(imageWidth(img())).toBe(600);
|
||||||
|
expect(button("Medium")!.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
// The draft is saved with the new width in it.
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining('width="600"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers them on a right-click too, instead of the browser's menu", async () => {
|
||||||
|
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
||||||
|
await act(async () => img().dispatchEvent(ev));
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(button("Original size")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts the frame or its buttons into the message", async () => {
|
||||||
|
await act(async () => img().click());
|
||||||
|
await act(async () => button("Small")!.click());
|
||||||
|
const sent = onChange.mock.lastCall![0] as string;
|
||||||
|
expect(sent).not.toContain("img-resize");
|
||||||
|
expect(sent).not.toContain("Original size");
|
||||||
|
expect(editor().querySelector(".img-resize-layer")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets go on a click elsewhere and on Escape", async () => {
|
||||||
|
await act(async () => img().click());
|
||||||
|
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||||
|
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => img().click());
|
||||||
|
await act(async () => editor().querySelector("p")!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
|
||||||
|
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -795,6 +795,9 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
|
|||||||
<span className="truncate">{who}</span>
|
<span className="truncate">{who}</span>
|
||||||
{count > 1 && <span className="thread-count"> {count}</span>}
|
{count > 1 && <span className="thread-count"> {count}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
{/* On the sender's line, which has room to spare: a third line of
|
||||||
|
their own did not fit the row at Cozy or Compact (#35). */}
|
||||||
|
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
||||||
<span className="msg-meta">
|
<span className="msg-meta">
|
||||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||||
@@ -808,7 +811,6 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
|
|||||||
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
|
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
|
||||||
const wasUnread = unreadAtOpen.current.ids;
|
const wasUnread = unreadAtOpen.current.ids;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Which messages have been read while this conversation was open.
|
||||||
|
*
|
||||||
|
* Mark as unread on an open message used to undo itself: clearing `$seen`
|
||||||
|
* re-ran the timer below, which found an expanded unread message and marked
|
||||||
|
* it read again a moment later (Gitea issue #25). A message that has been
|
||||||
|
* read here and is unread again was made so on purpose -- by this reader or
|
||||||
|
* on another device -- so the timer leaves it alone until the conversation
|
||||||
|
* is opened again. Mail that arrives unread was never read here, and is
|
||||||
|
* still marked read as before. Accumulated during render, like the set
|
||||||
|
* above.
|
||||||
|
*/
|
||||||
|
const readWhileOpen = useRef<{ key: Id | null; ids: Set<Id> }>({ key: null, ids: new Set() });
|
||||||
|
if (readWhileOpen.current.key !== threadKey) readWhileOpen.current = { key: threadKey, ids: new Set() };
|
||||||
|
for (const m of messages) if (m.keywords.$seen) readWhileOpen.current.ids.add(m.id);
|
||||||
|
|
||||||
// Default expansion: unread when opened + last message expanded, others collapsed
|
// Default expansion: unread when opened + last message expanded, others collapsed
|
||||||
const lastId = messages[messages.length - 1]?.id;
|
const lastId = messages[messages.length - 1]?.id;
|
||||||
const isExpanded = useCallback(
|
const isExpanded = useCallback(
|
||||||
@@ -137,7 +153,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
const readOnly = useViewingDelegation()?.access === "read";
|
const readOnly = useViewingDelegation()?.access === "read";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messages.length || readOnly) return;
|
if (!messages.length || readOnly) return;
|
||||||
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id);
|
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e) && !readWhileOpen.current.ids.has(e.id)).map((e) => e.id);
|
||||||
if (!unread.length || settings.markReadDelay < 0) return;
|
if (!unread.length || settings.markReadDelay < 0) return;
|
||||||
if (markTimer.current) window.clearTimeout(markTimer.current);
|
if (markTimer.current) window.clearTimeout(markTimer.current);
|
||||||
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
|
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ThreadView } from "../ThreadView";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import type { ListActions } from "../MessageList";
|
||||||
|
import type { Email, Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||||
|
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mark as unread on an open message undid itself: the unread count showed 1
|
||||||
|
* for a moment, then the message was read again (Gitea issue #25). Clearing
|
||||||
|
* `$seen` re-ran the mark-read timer, which found an open unread message and
|
||||||
|
* did its job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const msg = (id: Id, seen: boolean, receivedAt: string): Email =>
|
||||||
|
({
|
||||||
|
id, threadId: "t1", subject: "Hello", mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {},
|
||||||
|
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "Me", email: "[email protected]" }],
|
||||||
|
receivedAt, size: 10, blobId: "b1", preview: "hi",
|
||||||
|
htmlBody: [], textBody: [{ partId: "1", type: "text/plain" }],
|
||||||
|
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||||
|
attachments: [],
|
||||||
|
}) as unknown as Email;
|
||||||
|
|
||||||
|
describe("marking an open message unread", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let markRead: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const setEmails = async (emails: Email[]) => {
|
||||||
|
await act(async () => {
|
||||||
|
useMail.setState({
|
||||||
|
threads: { t1: { id: "t1", emailIds: emails.map((e) => e.id) } } as never,
|
||||||
|
emails: Object.fromEntries(emails.map((e) => [e.id, e])) as never,
|
||||||
|
fullIds: Object.fromEntries(emails.map((e) => [e.id, true])) as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = async () => {
|
||||||
|
const actions = {} as unknown as ListActions;
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ThreadView
|
||||||
|
threadId="t1" mailboxId="inbox" messageId={null} actions={actions}
|
||||||
|
onBack={() => undefined} onNavigate={() => undefined} hasPrev={false} hasNext={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTimers = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
markRead = vi.fn(async () => undefined);
|
||||||
|
useMail.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
loadingThreads: {} as never,
|
||||||
|
mailboxes: { inbox: { id: "inbox", name: "Inbox", role: "inbox" } } as never,
|
||||||
|
loadThread: (async () => undefined) as never,
|
||||||
|
setOpenThread: (() => undefined) as never,
|
||||||
|
markRead: markRead as never,
|
||||||
|
roleId: (() => null) as never,
|
||||||
|
});
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves it unread instead of marking it read again", async () => {
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// What the button does: the server clears $seen and the store follows.
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still marks a message read that was unread when the thread opened", async () => {
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledWith(["m1"], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-mark it after the timer has already read it once", async () => {
|
||||||
|
// Opened unread, read by the timer, then marked unread by the reader.
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledTimes(1);
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still marks a reply read that arrives while the thread is open", async () => {
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z"), msg("m2", false, "2026-10-01T11:00:00Z")]);
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledWith(["m2"], true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user