@novacraft-engineering/mailbox 0.2.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/mail/emails/route.ts +7 -5
- package/app/api/mail/inbox/counts/route.ts +6 -5
- package/app/api/mail/inbox/route.ts +6 -5
- package/app/api/mail/mailboxes/route.ts +27 -0
- package/app/api/mail/threads/route.ts +2 -1
- package/app/mail/MailSelect.tsx +11 -2
- package/app/mail/Ticker.tsx +34 -2
- package/app/mail/page.module.css +123 -1
- package/app/mail/page.tsx +361 -88
- package/app/mail/quoted.ts +162 -0
- package/app/mail/search.ts +50 -0
- package/lib/mailbox.ts +34 -14
- package/lib/scope.ts +34 -0
- package/package.json +6 -7
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
- package/tools/import-mbox.py +36 -11
- package/app/api/mail/upload/route.ts +0 -43
|
@@ -2,6 +2,7 @@ import { BRAND } from '@/lib/brand'
|
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { matchesQuery, parseQuery } from '@/app/mail/search'
|
|
4
4
|
import { FORWARD_RECIPIENTS, mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
5
|
+
import { scopeFor } from '@/lib/scope'
|
|
5
6
|
import { readSentFlags, setSentFlags, readPixelOpens, readSentMeta, setSentMetaOwner, listAccounts, readSentArchive, type SentFlags } from '@/lib/mailbox'
|
|
6
7
|
|
|
7
8
|
const SHARED_ADDRESS = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim().toLowerCase()
|
|
@@ -12,7 +13,7 @@ function fromAddressOf(email: SentEmail): string {
|
|
|
12
13
|
|
|
13
14
|
export const runtime = 'nodejs'
|
|
14
15
|
|
|
15
|
-
type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string }
|
|
16
|
+
type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string; attachmentCount: number }
|
|
16
17
|
|
|
17
18
|
const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
|
|
18
19
|
|
|
@@ -65,11 +66,12 @@ export async function GET(req: Request) {
|
|
|
65
66
|
createdAt: item.createdAt,
|
|
66
67
|
scheduledAt: null,
|
|
67
68
|
lastEvent: item.lastEvent ?? '',
|
|
69
|
+
attachmentCount: item.attachmentCount ?? 0,
|
|
68
70
|
}))
|
|
69
71
|
|
|
70
|
-
// Sent mail is scoped the same way the inbox is
|
|
71
|
-
//
|
|
72
|
-
const ownerScope
|
|
72
|
+
// Sent mail is scoped the same way the inbox is, including the one address a
|
|
73
|
+
// deployment may name as able to read every account.
|
|
74
|
+
const ownerScope = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
|
|
73
75
|
|
|
74
76
|
// Ownership is the sender identity: the `from` address if it's an accessor's, else the
|
|
75
77
|
// shared inbox (admin). This attributes historical sends (all from hello@) correctly.
|
|
@@ -106,7 +108,7 @@ export async function GET(req: Request) {
|
|
|
106
108
|
size: null,
|
|
107
109
|
read: true,
|
|
108
110
|
starred: Boolean(flagOf[email.id]?.starred),
|
|
109
|
-
hasAttachment:
|
|
111
|
+
hasAttachment: (email.attachmentCount ?? 0) > 0,
|
|
110
112
|
labels: [],
|
|
111
113
|
folder: flagOf[email.id]?.trashed ? 'trash' : flagOf[email.id]?.archived ? 'archive' : 'sent',
|
|
112
114
|
}),
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { scopeFor } from '@/lib/scope'
|
|
3
4
|
import { countFolders, countFoldersCached } from '@/lib/mailbox'
|
|
4
5
|
|
|
5
6
|
const COUNTS_TTL_MS = 20 * 1000
|
|
6
7
|
const countsCache = new Map<string, { at: number; value: Awaited<ReturnType<typeof countFolders>> }>()
|
|
7
|
-
async function cachedCounts(owner: string, fresh: boolean) {
|
|
8
|
-
const hit = countsCache.get(owner)
|
|
8
|
+
async function cachedCounts(owner: string | null, fresh: boolean) {
|
|
9
|
+
const hit = countsCache.get(owner ?? '*all')
|
|
9
10
|
if (!fresh && hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
|
|
10
|
-
const value = fresh ? await countFolders(owner) : await countFoldersCached(owner)
|
|
11
|
-
countsCache.set(owner, { at: Date.now(), value })
|
|
11
|
+
const value = fresh ? await countFolders(owner ?? undefined) : await countFoldersCached(owner)
|
|
12
|
+
countsCache.set(owner ?? '*all', { at: Date.now(), value })
|
|
12
13
|
return value
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -21,6 +22,6 @@ export async function GET(req: Request) {
|
|
|
21
22
|
// Counts describe the signed-in mailbox, matching what the list will actually show.
|
|
22
23
|
const account = await resolveAccount(req)
|
|
23
24
|
const fresh = new URL(req.url).searchParams.get('fresh') === '1'
|
|
24
|
-
const counts = await cachedCounts(account
|
|
25
|
+
const counts = await cachedCounts(scopeFor(account, new URL(req.url).searchParams.get('mailbox')), fresh)
|
|
25
26
|
return NextResponse.json({ ok: true, counts })
|
|
26
27
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { scopeFor } from '@/lib/scope'
|
|
4
5
|
import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
|
|
5
6
|
import { attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
|
|
6
7
|
import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
|
|
@@ -11,9 +12,9 @@ export async function GET(req: Request) {
|
|
|
11
12
|
const guard = await mailAuthGuard(req)
|
|
12
13
|
if (guard) return guard
|
|
13
14
|
const account = await resolveAccount(req)
|
|
14
|
-
// Everyone sees their own mailbox and only their own
|
|
15
|
-
//
|
|
16
|
-
const ownerFilter
|
|
15
|
+
// Everyone sees their own mailbox and only their own, unless this deployment names one
|
|
16
|
+
// address that reads every account. Administering accounts stays a separate power.
|
|
17
|
+
const ownerFilter = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
|
|
17
18
|
// A query or an explicit page means the caller wants the searchable, paged path.
|
|
18
19
|
// Without either, fall back to readInbox so existing callers are unaffected.
|
|
19
20
|
const params = new URL(req.url).searchParams
|
|
@@ -29,7 +30,7 @@ export async function GET(req: Request) {
|
|
|
29
30
|
const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam)
|
|
30
31
|
const { rows, total, nextCursor } = await searchInbox({
|
|
31
32
|
text,
|
|
32
|
-
owner: ownerFilter,
|
|
33
|
+
owner: ownerFilter ?? undefined,
|
|
33
34
|
folder,
|
|
34
35
|
limit: Number(limitRaw ?? 50) || 50,
|
|
35
36
|
offset,
|
|
@@ -37,7 +38,7 @@ export async function GET(req: Request) {
|
|
|
37
38
|
// Counting scans the whole match. Unfiltered, the sidebar already has this number
|
|
38
39
|
// from countFolders, so only a search — whose match nothing else knows — pays for it.
|
|
39
40
|
threadId,
|
|
40
|
-
withTotal: !cursor && Boolean(text),
|
|
41
|
+
withTotal: !cursor && Boolean(text || params.get('from') || params.get('to') || params.get('label') || params.get('unread') || params.get('starred') || params.get('attachment')),
|
|
41
42
|
unread: params.get('unread') === '1' ? true : undefined,
|
|
42
43
|
starred: params.get('starred') === '1' ? true : undefined,
|
|
43
44
|
hasAttachment: params.get('attachment') === '1' || undefined,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { listAccounts } from '@/lib/mailbox'
|
|
4
|
+
import { readsAllInboxes } from '@/lib/scope'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
|
|
8
|
+
export async function GET(req: Request) {
|
|
9
|
+
const guard = await mailAuthGuard(req)
|
|
10
|
+
if (guard) return guard
|
|
11
|
+
const account = await resolveAccount(req)
|
|
12
|
+
|
|
13
|
+
if (!readsAllInboxes(account)) {
|
|
14
|
+
const address = account.address
|
|
15
|
+
return NextResponse.json({
|
|
16
|
+
ok: true,
|
|
17
|
+
all: false,
|
|
18
|
+
mailboxes: address ? [{ address, name: account.name ?? address }] : [],
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const accounts = await listAccounts()
|
|
23
|
+
const mailboxes = accounts
|
|
24
|
+
.filter(entry => entry.address && entry.status === 'active')
|
|
25
|
+
.map(entry => ({ address: entry.address as string, name: entry.name || entry.email }))
|
|
26
|
+
return NextResponse.json({ ok: true, all: true, mailboxes })
|
|
27
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { scopeFor } from '@/lib/scope'
|
|
3
4
|
import { listThreads, threadsLive, type ThreadFolder } from '@/lib/mailbox'
|
|
4
5
|
|
|
5
6
|
export const runtime = 'nodejs'
|
|
@@ -13,6 +14,6 @@ export async function GET(req: Request) {
|
|
|
13
14
|
const folderParam = url.searchParams.get('folder')
|
|
14
15
|
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
15
16
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
|
|
16
|
-
const page = await listThreads(account.
|
|
17
|
+
const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'))
|
|
17
18
|
return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
|
|
18
19
|
}
|
package/app/mail/MailSelect.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export default function MailSelect({
|
|
|
41
41
|
label,
|
|
42
42
|
}: {
|
|
43
43
|
value: string
|
|
44
|
-
options: Array<{ value: string; label: string; closed?: string }>
|
|
44
|
+
options: Array<{ value: string; label: string; closed?: string; icon?: React.ReactNode; hint?: string }>
|
|
45
45
|
onChange: (value: string) => void
|
|
46
46
|
ariaLabel: string
|
|
47
47
|
buttonClassName?: string
|
|
@@ -125,6 +125,7 @@ export default function MailSelect({
|
|
|
125
125
|
onClick={toggle}
|
|
126
126
|
>
|
|
127
127
|
{prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
|
|
128
|
+
{current?.icon && <span className={styles.selectOptionIcon} aria-hidden>{current.icon}</span>}
|
|
128
129
|
{label && <span className={styles.selectLabel}>{label}</span>}
|
|
129
130
|
<span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{shown ?? placeholder}</span>
|
|
130
131
|
{CHEVRON}
|
|
@@ -147,7 +148,15 @@ export default function MailSelect({
|
|
|
147
148
|
setOpen(false)
|
|
148
149
|
}}
|
|
149
150
|
>
|
|
150
|
-
{option.
|
|
151
|
+
{option.icon && <span className={styles.selectOptionIcon} aria-hidden>{option.icon}</span>}
|
|
152
|
+
{option.hint ? (
|
|
153
|
+
<span className={styles.selectOptionText}>
|
|
154
|
+
<span className={styles.selectOptionLabel}>{option.label}</span>
|
|
155
|
+
<span className={styles.selectOptionHint}>{option.hint}</span>
|
|
156
|
+
</span>
|
|
157
|
+
) : (
|
|
158
|
+
option.label
|
|
159
|
+
)}
|
|
151
160
|
</button>
|
|
152
161
|
))}
|
|
153
162
|
</div>
|
package/app/mail/Ticker.tsx
CHANGED
|
@@ -3,17 +3,49 @@
|
|
|
3
3
|
import { useEffect, useRef, type ReactNode } from 'react'
|
|
4
4
|
import styles from './page.module.css'
|
|
5
5
|
|
|
6
|
+
export const TICKER_SPOTS = [
|
|
7
|
+
{ group: 'Mail list', on: false, spots: [['listFrom', 'Sender'], ['listSubject', 'Subject'], ['listSnippet', 'Preview']] },
|
|
8
|
+
{ group: 'Conversation', on: true, spots: [['threadFrom', 'Sender'], ['threadSnippet', 'Preview']] },
|
|
9
|
+
{ group: 'Reader', on: true, spots: [['readerFrom', 'From'], ['readerTo', 'To and Cc']] },
|
|
10
|
+
{ group: 'Attachments', on: true, spots: [['attachName', 'File names in the row'], ['attachChip', 'File names in the composer']] },
|
|
11
|
+
{ group: 'Reply bar', on: true, spots: [['replyRecipients', 'Recipients']] },
|
|
12
|
+
] as const
|
|
13
|
+
|
|
14
|
+
export type TickerSpot = (typeof TICKER_SPOTS)[number]['spots'][number][0]
|
|
15
|
+
export type TickerSettings = Partial<Record<TickerSpot, boolean>>
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ON = new Map<string, boolean>(TICKER_SPOTS.flatMap(group => group.spots.map(([spot]) => [spot, group.on])))
|
|
18
|
+
|
|
19
|
+
export function tickerDefault(spot: TickerSpot): boolean {
|
|
20
|
+
return DEFAULT_ON.get(spot) ?? true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function tickerSettingsFrom(raw: unknown): TickerSettings {
|
|
24
|
+
const kept: TickerSettings = {}
|
|
25
|
+
if (raw && typeof raw === 'object') {
|
|
26
|
+
for (const [spot, value] of Object.entries(raw)) {
|
|
27
|
+
if (DEFAULT_ON.has(spot) && typeof value === 'boolean') kept[spot as TickerSpot] = value
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return kept
|
|
31
|
+
}
|
|
32
|
+
|
|
6
33
|
/**
|
|
7
34
|
* One line of text that, when it does not fit, glides back and forth instead of ending in
|
|
8
35
|
* an ellipsis. Measured on mount and whenever the host or its text changes size.
|
|
9
36
|
*/
|
|
10
|
-
export default function Ticker({ className, title, children }: { className?: string; title?: string; children: ReactNode }) {
|
|
37
|
+
export default function Ticker({ className, title, enabled = true, children }: { className?: string; title?: string; enabled?: boolean; children: ReactNode }) {
|
|
11
38
|
const host = useRef<HTMLSpanElement>(null)
|
|
12
39
|
const text = useRef<HTMLSpanElement>(null)
|
|
13
40
|
useEffect(() => {
|
|
14
41
|
const outer = host.current
|
|
15
42
|
const inner = text.current
|
|
16
43
|
if (!outer || !inner) return
|
|
44
|
+
if (!enabled) {
|
|
45
|
+
delete outer.dataset.ticker
|
|
46
|
+
outer.style.removeProperty('--ticker-shift')
|
|
47
|
+
return
|
|
48
|
+
}
|
|
17
49
|
const measure = () => {
|
|
18
50
|
const shift = inner.scrollWidth - outer.clientWidth
|
|
19
51
|
if (shift > 4) {
|
|
@@ -31,7 +63,7 @@ export default function Ticker({ className, title, children }: { className?: str
|
|
|
31
63
|
observer.observe(outer)
|
|
32
64
|
observer.observe(inner)
|
|
33
65
|
return () => observer.disconnect()
|
|
34
|
-
}, [children])
|
|
66
|
+
}, [children, enabled])
|
|
35
67
|
return (
|
|
36
68
|
<span ref={host} className={`${className ?? ''} ${styles.tickerHost}`} title={title}>
|
|
37
69
|
<span ref={text} className={styles.tickerText}>{children}</span>
|
package/app/mail/page.module.css
CHANGED
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
.folderActive {
|
|
197
197
|
background: var(--nc-ink-3);
|
|
198
198
|
color: var(--nc-fg-1);
|
|
199
|
+
box-shadow: inset 0 0 0 1.5px var(--nc-violet-500);
|
|
199
200
|
}
|
|
200
201
|
.folder svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
201
202
|
/* Total for the folder — plain and muted. */
|
|
@@ -488,6 +489,39 @@
|
|
|
488
489
|
gap: 10px;
|
|
489
490
|
padding: 18px 18px 12px;
|
|
490
491
|
}
|
|
492
|
+
.mailboxPick {
|
|
493
|
+
display: flex;
|
|
494
|
+
align-items: center;
|
|
495
|
+
padding: 0 18px 10px;
|
|
496
|
+
}
|
|
497
|
+
.mailboxPickBtn {
|
|
498
|
+
max-width: 100%;
|
|
499
|
+
min-height: 36px;
|
|
500
|
+
}
|
|
501
|
+
.selectOption {
|
|
502
|
+
display: flex;
|
|
503
|
+
align-items: center;
|
|
504
|
+
gap: 10px;
|
|
505
|
+
}
|
|
506
|
+
.selectOptionIcon {
|
|
507
|
+
display: inline-flex;
|
|
508
|
+
flex-shrink: 0;
|
|
509
|
+
width: 18px;
|
|
510
|
+
height: 18px;
|
|
511
|
+
align-items: center;
|
|
512
|
+
justify-content: center;
|
|
513
|
+
}
|
|
514
|
+
.selectOptionIcon svg { width: 18px; height: 18px; }
|
|
515
|
+
.selectOptionText { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
516
|
+
.selectOptionLabel { font-weight: 600; }
|
|
517
|
+
.selectOptionHint {
|
|
518
|
+
color: var(--nc-fg-3, #8a8f99);
|
|
519
|
+
font: 400 11.5px/1.2 var(--nc-font-mono);
|
|
520
|
+
}
|
|
521
|
+
@media (max-width: 760px) {
|
|
522
|
+
.mailboxPickBtn { min-height: 44px; }
|
|
523
|
+
}
|
|
524
|
+
|
|
491
525
|
.listTitle {
|
|
492
526
|
font-family: var(--nc-font-display);
|
|
493
527
|
font-weight: 700;
|
|
@@ -527,6 +561,12 @@
|
|
|
527
561
|
cursor: pointer;
|
|
528
562
|
}
|
|
529
563
|
.crossFolderHint:hover { border-color: var(--nc-violet-500); }
|
|
564
|
+
.emptyNudge {
|
|
565
|
+
align-self: center;
|
|
566
|
+
margin: 2px 0 0;
|
|
567
|
+
padding: 8px 14px;
|
|
568
|
+
font-size: 13px;
|
|
569
|
+
}
|
|
530
570
|
.refreshBtn svg { width: 14px; height: 14px; }
|
|
531
571
|
.filterChip {
|
|
532
572
|
margin-left: auto;
|
|
@@ -1717,7 +1757,7 @@
|
|
|
1717
1757
|
.itemSubject { display: flex; align-items: center; gap: 4px; }
|
|
1718
1758
|
.itemSubjectText { flex: 1; }
|
|
1719
1759
|
.threadMsgFrom { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; }
|
|
1720
|
-
.threadMsgFromText { flex: 1; }
|
|
1760
|
+
.threadMsgFromText { flex: 0 1 auto; min-width: 0; }
|
|
1721
1761
|
.rteColourDot {
|
|
1722
1762
|
display: inline-block;
|
|
1723
1763
|
width: 16px;
|
|
@@ -2560,6 +2600,40 @@
|
|
|
2560
2600
|
overflow: hidden;
|
|
2561
2601
|
background: transparent;
|
|
2562
2602
|
}
|
|
2603
|
+
.quoteLine {
|
|
2604
|
+
display: flex;
|
|
2605
|
+
align-items: center;
|
|
2606
|
+
gap: 10px;
|
|
2607
|
+
width: 100%;
|
|
2608
|
+
margin: 2px 0 14px;
|
|
2609
|
+
padding: 0;
|
|
2610
|
+
border: 0;
|
|
2611
|
+
background: transparent;
|
|
2612
|
+
cursor: pointer;
|
|
2613
|
+
}
|
|
2614
|
+
.quoteLineRule {
|
|
2615
|
+
flex: 1;
|
|
2616
|
+
height: 1px;
|
|
2617
|
+
background: var(--nc-line-1);
|
|
2618
|
+
}
|
|
2619
|
+
.quoteLineLabel {
|
|
2620
|
+
display: inline-flex;
|
|
2621
|
+
align-items: center;
|
|
2622
|
+
gap: 5px;
|
|
2623
|
+
flex-shrink: 0;
|
|
2624
|
+
padding: 4px 11px;
|
|
2625
|
+
border: 1px solid var(--nc-line-1);
|
|
2626
|
+
border-radius: 999px;
|
|
2627
|
+
background: var(--nc-ink-2);
|
|
2628
|
+
color: var(--nc-fg-3);
|
|
2629
|
+
font: 500 11.5px/1 var(--nc-font-sans);
|
|
2630
|
+
transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
|
|
2631
|
+
}
|
|
2632
|
+
.quoteLineLabel svg { width: 11px; height: 8px; }
|
|
2633
|
+
.quoteLine:hover .quoteLineLabel { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
|
|
2634
|
+
.quoteLine[aria-expanded='true'] .quoteLineLabel svg { transform: rotate(180deg); }
|
|
2635
|
+
|
|
2636
|
+
.threadMsgStatic .threadMsgHead { cursor: default; }
|
|
2563
2637
|
.threadMsg + .threadMsg { border-top: 1px solid var(--nc-line-1); }
|
|
2564
2638
|
.threadMsgOpen .threadMsgHead { border-bottom: 1px solid var(--nc-line-1); }
|
|
2565
2639
|
.threadMsgHead {
|
|
@@ -2601,6 +2675,42 @@
|
|
|
2601
2675
|
text-overflow: ellipsis;
|
|
2602
2676
|
white-space: nowrap;
|
|
2603
2677
|
}
|
|
2678
|
+
.threadMsgHint {
|
|
2679
|
+
flex-shrink: 0;
|
|
2680
|
+
color: var(--nc-fg-4);
|
|
2681
|
+
font: 400 11px/1 var(--nc-font-sans);
|
|
2682
|
+
transition: color var(--dur-fast) var(--nc-ease-out);
|
|
2683
|
+
}
|
|
2684
|
+
.threadMsgHead:hover .threadMsgHint { color: var(--nc-fg-3); }
|
|
2685
|
+
|
|
2686
|
+
.threadMsgRecips {
|
|
2687
|
+
display: flex;
|
|
2688
|
+
align-items: baseline;
|
|
2689
|
+
justify-content: flex-start;
|
|
2690
|
+
gap: 8px;
|
|
2691
|
+
width: 100%;
|
|
2692
|
+
min-width: 0;
|
|
2693
|
+
margin-top: 3px;
|
|
2694
|
+
overflow: hidden;
|
|
2695
|
+
}
|
|
2696
|
+
.threadMsgRecipsText {
|
|
2697
|
+
/* Hugs the addresses so Details sits beside them rather than out at the far margin. */
|
|
2698
|
+
flex: 0 1 auto;
|
|
2699
|
+
min-width: 0;
|
|
2700
|
+
font: 400 11.5px/1.35 var(--nc-font-sans);
|
|
2701
|
+
color: var(--nc-fg-3);
|
|
2702
|
+
}
|
|
2703
|
+
.threadMsgDetails {
|
|
2704
|
+
flex-shrink: 0;
|
|
2705
|
+
padding: 0;
|
|
2706
|
+
border: 0;
|
|
2707
|
+
background: transparent;
|
|
2708
|
+
color: var(--nc-violet-500);
|
|
2709
|
+
font: 500 11px/1.35 var(--nc-font-sans);
|
|
2710
|
+
cursor: pointer;
|
|
2711
|
+
}
|
|
2712
|
+
.threadMsgDetails:hover { text-decoration: underline; }
|
|
2713
|
+
|
|
2604
2714
|
.threadMsgDate {
|
|
2605
2715
|
font: 400 11px/1 var(--nc-font-mono);
|
|
2606
2716
|
color: var(--nc-fg-3);
|
|
@@ -3945,6 +4055,18 @@
|
|
|
3945
4055
|
font: 500 14px/1.4 var(--nc-font-sans);
|
|
3946
4056
|
color: var(--nc-fg-1);
|
|
3947
4057
|
}
|
|
4058
|
+
.tickerGroup {
|
|
4059
|
+
display: flex;
|
|
4060
|
+
flex-direction: column;
|
|
4061
|
+
gap: 6px;
|
|
4062
|
+
margin-top: 12px;
|
|
4063
|
+
}
|
|
4064
|
+
.tickerGroupName {
|
|
4065
|
+
font: 600 11px/1 var(--nc-font-mono);
|
|
4066
|
+
letter-spacing: .04em;
|
|
4067
|
+
text-transform: uppercase;
|
|
4068
|
+
color: var(--nc-fg-3);
|
|
4069
|
+
}
|
|
3948
4070
|
.settingsToggle input {
|
|
3949
4071
|
width: 16px;
|
|
3950
4072
|
height: 16px;
|