- Read receipts: MarkSenderMessagesReadCrossConv matches by body across conversations - Anti-fraud: cross-domain read receipts require SHA-256 body proof - Real-time polling: 5s interval refreshes threads + active thread messages - Notification sound: Web Audio API beep on new unread messages - Unread indicator: conv.Unread=true on inbound message delivery - Double-check icon: custom SVG in web_src/svg/ (survives make svg) - SMTP icon: octicon-mail for email-delivered messages - Delivery status: same-domain=M8SH check, external=SMTP mail icon, failed=cross - Plain mode: global toggle for mobile text selection - Desktop email layout: no width stripping, mobile-only shrinking - Registration allowlist: domestic users require admin approval Assisted-By: Cline:anthropic-claude-sonnet-4-20250514
400 lines
11 KiB
CSS
400 lines
11 KiB
CSS
/* Copyright 2026 The M8SH Authors. */
|
|
/* SPDX-License-Identifier: GPL-3.0 */
|
|
|
|
/* The mail page fills the viewport below the navbar and above the footer.
|
|
The body is a flex column: [navbar wrapper .full.height] then [footer].
|
|
.full.height has flex-grow:1, so it already fills everything except the footer.
|
|
We make it a flex container so the mail page can flex:1 within it, sitting below the navbar. */
|
|
|
|
/* When the mail page is active, make .full.height a column flex container so
|
|
the navbar stays fixed at top and the mail page fills the remaining height. */
|
|
.full.height:has(.mail-page) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
padding-bottom: 0; /* mail manages its own bottom spacing */
|
|
}
|
|
|
|
.page-content.mail-page {
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.page-content.mail-page > #mail-app {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* On mobile, hide the footer so the mail app uses the full viewport height. */
|
|
@media (max-width: 639.98px) {
|
|
.full.height:has(.mail-page) ~ .page-footer {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* In-thread search highlight. Global (not scoped) because it must reach
|
|
<mark> nodes produced via v-html inside bubbles and html cards. */
|
|
#mail-app mark.search-hit {
|
|
background: var(--color-highlight-bg);
|
|
color: var(--color-text);
|
|
border-radius: 2px;
|
|
padding: 0 1px;
|
|
}
|
|
|
|
#mail-app mark.search-hit.current {
|
|
outline: 1px solid var(--color-highlight-fg);
|
|
}
|
|
|
|
/* Thread context menu. It is teleported to <body> (outside the Vue scoped
|
|
component subtree), so its styles live here, unscoped. */
|
|
body > .ctx-menu {
|
|
position: fixed;
|
|
z-index: 1002;
|
|
min-width: 140px;
|
|
background: var(--color-menu);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
body > .ctx-menu .ctx-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
padding: 7px 10px;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
min-height: 36px;
|
|
}
|
|
|
|
body > .ctx-menu .ctx-item:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
body > .ctx-menu .ctx-item.danger:hover {
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
/* Delete conversation confirmation dialog (teleported, so unscoped). */
|
|
body > .confirm-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1003;
|
|
background: var(--color-overlay-backdrop);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 16px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-modal {
|
|
width: 100%;
|
|
max-width: 360px;
|
|
background: var(--color-menu);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--border-radius-full);
|
|
background: var(--color-red-badge-bg);
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-title {
|
|
font-size: 15px;
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-desc {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
color: var(--color-text-light-2);
|
|
text-align: center;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-cancel {
|
|
flex: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
background: var(--color-body);
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
padding: 8px 12px;
|
|
border-radius: var(--border-radius-medium);
|
|
cursor: pointer;
|
|
min-height: 38px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-cancel:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-delete {
|
|
flex: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
border: none;
|
|
background: var(--color-danger);
|
|
color: var(--color-white);
|
|
font-size: 13px;
|
|
padding: 8px 12px;
|
|
border-radius: var(--border-radius-medium);
|
|
cursor: pointer;
|
|
min-height: 38px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-delete:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* Block toggle row: a right-aligned chevron that flips up when expanded. */
|
|
body > .ctx-menu .ctx-toggle .ctx-chev {
|
|
margin-left: auto;
|
|
color: var(--color-text-light-2);
|
|
transition: transform 0.12s ease;
|
|
}
|
|
body > .ctx-menu .ctx-toggle .ctx-chev.up {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
/* Expandable Block panel: its option buttons go edge-to-edge on hover so the
|
|
whole row is the target, not just the centered label. */
|
|
body > .ctx-menu .ctx-expand {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
padding-top: 2px;
|
|
border-top: 0.5px solid var(--color-secondary-alpha-30);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
body > .ctx-menu .ctx-expand .ctx-opt {
|
|
width: 100%;
|
|
border-radius: 0;
|
|
padding-left: 10px; /* flush with the other menu items' text, no icon column */
|
|
}
|
|
body > .ctx-menu .ctx-expand .ctx-opt:hover {
|
|
width: 100%;
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
/* Delete confirmation label inside context menu */
|
|
body > .ctx-menu .ctx-confirm-label {
|
|
padding: 6px 12px 2px;
|
|
font-size: 11px;
|
|
color: var(--color-text-light-2);
|
|
}
|
|
|
|
/* Rename group dialog input */
|
|
body > .confirm-overlay .rename-input {
|
|
width: 100%;
|
|
font-size: 14px;
|
|
font-family: var(--fonts-regular);
|
|
color: var(--color-text);
|
|
background: var(--color-secondary-bg);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 8px 10px;
|
|
outline: none;
|
|
}
|
|
body > .confirm-overlay .rename-input:focus {
|
|
border-color: var(--color-primary);
|
|
}
|
|
body > .confirm-overlay .rename-modal {
|
|
align-items: stretch;
|
|
}
|
|
|
|
/* Mobile overflow menu: transparent full-screen click catcher with the menu
|
|
positioned in the top-right corner, just like the context menu (no darkening). */
|
|
body > .overflow-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1002;
|
|
}
|
|
|
|
body > .overflow-backdrop .overflow-menu {
|
|
position: absolute;
|
|
top: 56px;
|
|
right: 8px;
|
|
min-width: 160px;
|
|
background: var(--color-menu);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
body > .overflow-backdrop .ctx-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
padding: 7px 10px;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
min-height: 36px;
|
|
}
|
|
|
|
body > .overflow-backdrop .ctx-item:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
body > .overflow-backdrop .ctx-item.danger:hover {
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
|
|
/* Audio player buttons: instant hover, no delay (overrides all inherited transitions) */
|
|
#mail-app .audio-player a.ap-btn,
|
|
#mail-app .audio-player button.ap-btn,
|
|
#mail-app .audio-player a.ap-download,
|
|
#mail-app .audio-player .ap-download,
|
|
#mail-app .audio-player .ap-close,
|
|
#mail-app .audio-player .ap-btn,
|
|
#mail-app .audio-player .ap-btn:hover {
|
|
transition: none !important;
|
|
transition-property: none !important;
|
|
transition-duration: 0s !important;
|
|
transition-delay: 0s !important;
|
|
}
|
|
|
|
/* Mobile: prevent iOS auto-zoom on input focus by ensuring font-size >= 16px
|
|
on ALL form elements. The viewport meta override handles most cases, but newer
|
|
iOS versions sometimes ignore user-scalable=no, so this is a belt-and-suspenders
|
|
fix. Covers textareas, inputs (all types), and contenteditable elements. */
|
|
@media (max-width: 639.98px) {
|
|
#mail-app input,
|
|
#mail-app textarea,
|
|
#mail-app select,
|
|
#mail-app [contenteditable],
|
|
body > .confirm-overlay input,
|
|
body > .confirm-overlay textarea,
|
|
body > .fwd-overlay input,
|
|
body > .fwd-overlay textarea,
|
|
body > .ctx-menu input,
|
|
body > .overflow-menu input {
|
|
font-size: 16px !important;
|
|
}
|
|
}
|
|
|
|
/* Interactive UI elements (context menu, overflow menu, thread list items)
|
|
always disable text selection. */
|
|
#mail-app .thread-item,
|
|
#mail-app .ctx-menu,
|
|
#mail-app .overflow-menu {
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Desktop: message content allows free text selection. */
|
|
#mail-app .msg-row,
|
|
#mail-app .html-card {
|
|
-webkit-user-select: text;
|
|
-moz-user-select: text;
|
|
-ms-user-select: text;
|
|
user-select: text;
|
|
}
|
|
|
|
/* Mobile: disable text selection on ALL message content by default.
|
|
This prevents the native system copy/select popup from appearing alongside
|
|
our custom context menu on long-press. Only messages explicitly marked with
|
|
.plain-msg class (via the "Plain" button) get text selection re-enabled,
|
|
which also allows the native copy UI to appear for that specific message.
|
|
This is a robust cross-browser solution: user-select:none is universally
|
|
supported on iOS Safari, Chrome Android, Firefox, etc. */
|
|
@media (max-width: 639.98px) {
|
|
#mail-app .msg-row,
|
|
#mail-app .html-card {
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
-webkit-touch-callout: none;
|
|
}
|
|
|
|
/* Global plain mode: ALL messages get text selection + native callout.
|
|
The .plain-mode class is on the chat-pane root element. */
|
|
#mail-app .plain-mode .msg-row,
|
|
#mail-app .plain-mode .msg-row .msg-bubble,
|
|
#mail-app .plain-mode .msg-row .msg-text,
|
|
#mail-app .plain-mode .html-card,
|
|
#mail-app .plain-mode .html-card .html-body,
|
|
#mail-app .plain-mode .html-body {
|
|
-webkit-user-select: text !important;
|
|
-moz-user-select: text !important;
|
|
-ms-user-select: text !important;
|
|
user-select: text !important;
|
|
-webkit-touch-callout: default !important;
|
|
}
|
|
}
|
|
|
|
/* Prevent double-tap-to-zoom on mobile. touch-action: manipulation allows
|
|
panning and pinching but disables double-tap zoom gesture. */
|
|
@media (max-width: 639.98px) {
|
|
#mail-app,
|
|
body > .confirm-overlay,
|
|
body > .fwd-overlay,
|
|
body > .ctx-menu,
|
|
body > .overflow-menu,
|
|
body > .att-lightbox {
|
|
touch-action: manipulation;
|
|
}
|
|
}
|
|
|
|
/* Rendering optimization: content-visibility:auto lets the browser skip
|
|
rendering off-screen message bubbles entirely. contain-intrinsic-size
|
|
provides a placeholder height so scrollbar calculations work without
|
|
actually rendering the content. This makes large conversations smooth
|
|
regardless of message count. */
|
|
#mail-app .msg-row {
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 60px;
|
|
}
|
|
|
|
#mail-app .html-card {
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 120px;
|
|
}
|