@novacraft-engineering/mailbox 0.4.4 → 0.4.17
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/README.md +17 -0
- package/app/api/mail/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- package/app/api/mail/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- package/app/api/mail/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- package/app/api/mail/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +39 -7
- package/app/api/mail/threads/route.ts +1 -1
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +254 -1
- package/app/mail/page.tsx +539 -87
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +29 -9
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +39 -3
- package/lib/mailbox.ts +578 -51
- package/lib/receive.test.ts +25 -0
- package/lib/receive.ts +117 -17
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/lib/share-ticket.ts +42 -0
- package/package.json +1 -1
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { BRAND } from '@/lib/brand'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
3
|
import { NextResponse } from 'next/server'
|
|
4
|
-
import { sendMail } from '@/lib/mail-provider'
|
|
4
|
+
import { providerConfigProblem, sendMail } from '@/lib/mail-provider'
|
|
5
5
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
6
6
|
import { presign } from '@/lib/r2'
|
|
7
7
|
import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
8
|
+
import { scheduleSend } from '@/lib/scheduled'
|
|
8
9
|
import { publicOrigin } from '@/lib/public-url'
|
|
9
10
|
|
|
10
11
|
export const runtime = 'nodejs'
|
|
@@ -31,9 +32,9 @@ export async function POST(req: Request) {
|
|
|
31
32
|
|
|
32
33
|
const account = await resolveAccount(req)
|
|
33
34
|
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
return NextResponse.json({ ok: false, error:
|
|
35
|
+
const configProblem = providerConfigProblem()
|
|
36
|
+
if (configProblem) {
|
|
37
|
+
return NextResponse.json({ ok: false, error: configProblem }, { status: 500 })
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
let body: SendBody
|
|
@@ -93,9 +94,10 @@ export async function POST(req: Request) {
|
|
|
93
94
|
|
|
94
95
|
const rawAttachments = (body.attachments ?? []).filter(entry => entry.filename && (entry.content || entry.path || entry.key))
|
|
95
96
|
if (rawAttachments.length && body.scheduledAt) {
|
|
96
|
-
//
|
|
97
|
+
// The message waits here rather than at a provider, and holding file bytes in the
|
|
98
|
+
// queue for hours is a cost this has not taken on yet.
|
|
97
99
|
return NextResponse.json(
|
|
98
|
-
{ ok: false, error: '
|
|
100
|
+
{ ok: false, error: 'A scheduled email cannot carry attachments yet — send it now, or schedule it without files' },
|
|
99
101
|
{ status: 400 },
|
|
100
102
|
)
|
|
101
103
|
}
|
|
@@ -155,6 +157,37 @@ export async function POST(req: Request) {
|
|
|
155
157
|
const fromEmail = from.replace(/^.*<|>$/g, '').trim()
|
|
156
158
|
const fromName = from.includes('<') ? from.slice(0, from.indexOf('<')).replace(/["']/g, '').trim() : undefined
|
|
157
159
|
|
|
160
|
+
const dueAt = body.scheduledAt ? new Date(body.scheduledAt) : null
|
|
161
|
+
if (dueAt && !Number.isNaN(dueAt.getTime()) && dueAt.getTime() > Date.now() + 5_000) {
|
|
162
|
+
// Held here, not handed over: SES has no scheduling and drops the instruction without
|
|
163
|
+
// a word, so "in an hour" arrived at once. The dispatcher sends it when it is due.
|
|
164
|
+
const scheduledId = await scheduleSend(account.address ?? fromEmail, dueAt.toISOString(), {
|
|
165
|
+
owner: account.address ?? fromEmail,
|
|
166
|
+
pixelId: trackedHtml ? pixelId : null,
|
|
167
|
+
subject: body.subject.trim(),
|
|
168
|
+
from,
|
|
169
|
+
recipients,
|
|
170
|
+
cc: body.cc ?? [],
|
|
171
|
+
bcc: body.bcc ?? [],
|
|
172
|
+
replyTo: replyTo ? [replyTo] : [],
|
|
173
|
+
inReplyTo: body.inReplyTo ?? null,
|
|
174
|
+
storedAttachments: [],
|
|
175
|
+
payload: {
|
|
176
|
+
from: fromEmail,
|
|
177
|
+
fromName: fromName || undefined,
|
|
178
|
+
to: recipients,
|
|
179
|
+
cc: body.cc,
|
|
180
|
+
bcc: body.bcc,
|
|
181
|
+
replyTo,
|
|
182
|
+
subject: body.subject.trim(),
|
|
183
|
+
html: trackedHtml,
|
|
184
|
+
text: body.text,
|
|
185
|
+
...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
return NextResponse.json({ ok: true, id: scheduledId, scheduled: true, scheduledAt: dueAt.toISOString() })
|
|
189
|
+
}
|
|
190
|
+
|
|
158
191
|
let data: { id: string | null }
|
|
159
192
|
try {
|
|
160
193
|
data = await sendMail({
|
|
@@ -167,7 +200,6 @@ export async function POST(req: Request) {
|
|
|
167
200
|
subject: body.subject.trim(),
|
|
168
201
|
html: trackedHtml,
|
|
169
202
|
text: body.text,
|
|
170
|
-
scheduledAt: body.scheduledAt,
|
|
171
203
|
attachments,
|
|
172
204
|
...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
|
|
173
205
|
})
|
|
@@ -12,7 +12,7 @@ export async function GET(req: Request) {
|
|
|
12
12
|
const account = await resolveAccount(req)
|
|
13
13
|
const url = new URL(req.url)
|
|
14
14
|
const folderParam = url.searchParams.get('folder')
|
|
15
|
-
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
15
|
+
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
16
16
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
|
|
17
17
|
const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'))
|
|
18
18
|
return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getShare } from '@/lib/mailbox'
|
|
3
|
+
import { getObject, objectExists } from '@/lib/r2'
|
|
4
|
+
import { shareTicketValid } from '@/lib/share-ticket'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
const GONE = { ok: false as const, error: 'This link is no longer available.' }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The bytes of a shared file, served from this domain.
|
|
13
|
+
*
|
|
14
|
+
* The share page used to send the browser to a signed bucket URL. That is a second
|
|
15
|
+
* hostname for the recipient's network to resolve and reach, and on the networks this
|
|
16
|
+
* office deals with it sometimes cannot be — which looked exactly like a dead button,
|
|
17
|
+
* because a failed navigation reports nothing back to the page it left. Every other
|
|
18
|
+
* attachment in this product is already served from here; this one now is too.
|
|
19
|
+
*/
|
|
20
|
+
async function resolve(id: string, ticket: string | null) {
|
|
21
|
+
if (!shareTicketValid(ticket, id)) return { error: NextResponse.json(GONE, { status: 403 }) }
|
|
22
|
+
const share = await getShare(id)
|
|
23
|
+
if (!share || share.revoked) return { error: NextResponse.json(GONE, { status: 404 }) }
|
|
24
|
+
if (share.expiresAt && new Date(share.expiresAt) < new Date()) {
|
|
25
|
+
return { error: NextResponse.json(GONE, { status: 410 }) }
|
|
26
|
+
}
|
|
27
|
+
return { share }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function disposition(filename: string): string {
|
|
31
|
+
const plain = filename.replace(/["\\]/g, '')
|
|
32
|
+
return `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Lets the page check the file is reachable before it sends the reader away from it. */
|
|
36
|
+
export async function HEAD(req: Request, context: { params: Promise<{ id: string }> }) {
|
|
37
|
+
const { id } = await context.params
|
|
38
|
+
const { share, error } = await resolve(id, new URL(req.url).searchParams.get('t'))
|
|
39
|
+
if (error) return error
|
|
40
|
+
if (!(await objectExists(share.objectKey))) return NextResponse.json(GONE, { status: 410 })
|
|
41
|
+
return new Response(null, {
|
|
42
|
+
headers: {
|
|
43
|
+
'content-type': share.contentType || 'application/octet-stream',
|
|
44
|
+
'content-length': String(share.size),
|
|
45
|
+
'content-disposition': disposition(share.filename),
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
|
|
51
|
+
const { id } = await context.params
|
|
52
|
+
const { share, error } = await resolve(id, new URL(req.url).searchParams.get('t'))
|
|
53
|
+
if (error) return error
|
|
54
|
+
|
|
55
|
+
const object = await getObject(share.objectKey)
|
|
56
|
+
if (!object?.body) {
|
|
57
|
+
return NextResponse.json(
|
|
58
|
+
{ ok: false, error: 'This file is no longer stored. Ask whoever sent it to upload it again.' },
|
|
59
|
+
{ status: 410 },
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const length = object.headers.get('content-length')
|
|
63
|
+
return new Response(object.body, {
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type': share.contentType || object.headers.get('content-type') || 'application/octet-stream',
|
|
66
|
+
'content-disposition': disposition(share.filename),
|
|
67
|
+
...(length ? { 'content-length': length } : {}),
|
|
68
|
+
'cache-control': 'private, no-store',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { getShare, getSharePasswordHash, recordShareDownload } from '@/lib/mailbox'
|
|
3
3
|
import { verifyPassword } from '@/lib/password'
|
|
4
|
-
import { objectExists
|
|
4
|
+
import { objectExists } from '@/lib/r2'
|
|
5
5
|
import { clientKey, rateLimit } from '@/lib/rate-limit'
|
|
6
|
+
import { issueShareTicket } from '@/lib/share-ticket'
|
|
6
7
|
|
|
7
8
|
export const runtime = 'nodejs'
|
|
8
9
|
export const dynamic = 'force-dynamic'
|
|
@@ -37,7 +38,7 @@ export async function GET(_req: Request, context: { params: Promise<{ id: string
|
|
|
37
38
|
})
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** Exchanges the password for a short-lived
|
|
41
|
+
/** Exchanges the password for a short-lived link to the bytes, served from this domain. */
|
|
41
42
|
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
|
42
43
|
const { id } = await context.params
|
|
43
44
|
|
|
@@ -77,9 +78,13 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
await recordShareDownload(id)
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Served from this domain rather than the bucket: the bucket is a second hostname for
|
|
82
|
+
// the recipient's network to reach, and when it cannot the download silently never
|
|
83
|
+
// starts. The ticket carries the password decision the short distance to the bytes.
|
|
84
|
+
const ticket = issueShareTicket(id)
|
|
85
|
+
if (!ticket) {
|
|
86
|
+
return NextResponse.json({ ok: false, error: 'Downloads are not configured.' }, { status: 503 })
|
|
87
|
+
}
|
|
88
|
+
const url = `/api/share/${encodeURIComponent(id)}/download?t=${encodeURIComponent(ticket)}`
|
|
84
89
|
return NextResponse.json({ ok: true, url, filename: share.filename })
|
|
85
90
|
}
|
package/app/globals.css
CHANGED
|
@@ -11,4 +11,11 @@ body {
|
|
|
11
11
|
margin: 0;
|
|
12
12
|
padding: 0;
|
|
13
13
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
14
|
+
/* Safari on iOS grows text block by block on its own reckoning, each by a different
|
|
15
|
+
amount, so a snippet came out larger than the subject above it and the list read as
|
|
16
|
+
though the sizes had been picked at random. Nothing else does this, which is why it
|
|
17
|
+
only ever showed on a phone. This pins the rendered size to the stated one; pinch to
|
|
18
|
+
zoom and the reader's own text-size setting are untouched. */
|
|
19
|
+
-webkit-text-size-adjust: 100%;
|
|
20
|
+
text-size-adjust: 100%;
|
|
14
21
|
}
|
|
@@ -35,7 +35,7 @@ export function attachmentKind(filename: string, contentType?: string): Attachme
|
|
|
35
35
|
* browser to save. Anything rendered here asks for the inline form instead, or a PDF opens
|
|
36
36
|
* the save dialog the moment the viewer appears.
|
|
37
37
|
*/
|
|
38
|
-
function inlineUrl(url: string): string {
|
|
38
|
+
export function inlineUrl(url: string): string {
|
|
39
39
|
if (!url.startsWith('/api/mail/')) return url
|
|
40
40
|
return `${url}${url.includes('?') ? '&' : '?'}inline=1`
|
|
41
41
|
}
|
|
@@ -11,40 +11,64 @@ export type ConfirmRequest = {
|
|
|
11
11
|
cancelLabel?: string
|
|
12
12
|
/** Paints the confirm button as destructive. */
|
|
13
13
|
danger?: boolean
|
|
14
|
+
/** Asks for a value as well, replacing window.prompt. */
|
|
15
|
+
field?: { label: string; type?: 'text' | 'password'; placeholder?: string; initial?: string }
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
type
|
|
18
|
+
type Settled = { ok: boolean; value: string }
|
|
19
|
+
type Pending = ConfirmRequest & { resolve: (value: Settled) => void }
|
|
17
20
|
|
|
18
|
-
const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<
|
|
21
|
+
const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<Settled>>(async () => ({ ok: false, value: '' }))
|
|
19
22
|
|
|
20
23
|
/** Replaces window.confirm, which cannot be styled and looks like the browser, not the app. */
|
|
21
24
|
export function useConfirm() {
|
|
22
|
-
|
|
25
|
+
const ask = useContext(ConfirmContext)
|
|
26
|
+
return useCallback(async (request: ConfirmRequest) => (await ask(request)).ok, [ask])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Replaces window.prompt. Resolves to null when dismissed, so a blank answer stays meaningful. */
|
|
30
|
+
export function usePrompt() {
|
|
31
|
+
const ask = useContext(ConfirmContext)
|
|
32
|
+
return useCallback(
|
|
33
|
+
async (request: ConfirmRequest & { field: NonNullable<ConfirmRequest['field']> }) => {
|
|
34
|
+
const settled = await ask(request)
|
|
35
|
+
return settled.ok ? settled.value : null
|
|
36
|
+
},
|
|
37
|
+
[ask],
|
|
38
|
+
)
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
26
42
|
const [pending, setPending] = useState<Pending | null>(null)
|
|
43
|
+
const [draft, setDraft] = useState('')
|
|
44
|
+
const [reveal, setReveal] = useState(false)
|
|
27
45
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
|
46
|
+
const fieldRef = useRef<HTMLInputElement>(null)
|
|
28
47
|
|
|
29
48
|
const confirm = useCallback(
|
|
30
|
-
(request: ConfirmRequest) => new Promise<
|
|
49
|
+
(request: ConfirmRequest) => new Promise<Settled>(resolve => {
|
|
50
|
+
setDraft(request.field?.initial ?? '')
|
|
51
|
+
setReveal(false)
|
|
52
|
+
setPending({ ...request, resolve })
|
|
53
|
+
}),
|
|
31
54
|
[],
|
|
32
55
|
)
|
|
33
56
|
|
|
34
57
|
const settle = useCallback(
|
|
35
|
-
(
|
|
58
|
+
(ok: boolean) => {
|
|
36
59
|
setPending(current => {
|
|
37
|
-
current?.resolve(value)
|
|
60
|
+
current?.resolve({ ok, value: draft })
|
|
38
61
|
return null
|
|
39
62
|
})
|
|
40
63
|
},
|
|
41
|
-
[],
|
|
64
|
+
[draft],
|
|
42
65
|
)
|
|
43
66
|
|
|
44
67
|
// Escape cancels and Enter confirms, matching what the native dialog did.
|
|
45
68
|
useEffect(() => {
|
|
46
69
|
if (!pending) return
|
|
47
|
-
|
|
70
|
+
if (pending.field) fieldRef.current?.focus()
|
|
71
|
+
else confirmRef.current?.focus()
|
|
48
72
|
const onKey = (event: KeyboardEvent) => {
|
|
49
73
|
if (event.key === 'Escape') { event.preventDefault(); settle(false) }
|
|
50
74
|
if (event.key === 'Enter') { event.preventDefault(); settle(true) }
|
|
@@ -67,6 +91,27 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
|
67
91
|
>
|
|
68
92
|
<p className={styles.confirmTitle}>{pending.title}</p>
|
|
69
93
|
{pending.body && <div className={styles.confirmBody}>{pending.body}</div>}
|
|
94
|
+
{pending.field && (
|
|
95
|
+
<label className={styles.confirmField}>
|
|
96
|
+
<span>{pending.field.label}</span>
|
|
97
|
+
<div className={styles.pwWrap}>
|
|
98
|
+
<input
|
|
99
|
+
ref={fieldRef}
|
|
100
|
+
className={styles.confirmInput}
|
|
101
|
+
type={pending.field.type === 'password' && !reveal ? 'password' : 'text'}
|
|
102
|
+
autoComplete={pending.field.type === 'password' ? 'new-password' : 'off'}
|
|
103
|
+
placeholder={pending.field.placeholder}
|
|
104
|
+
value={draft}
|
|
105
|
+
onChange={event => setDraft(event.target.value)}
|
|
106
|
+
/>
|
|
107
|
+
{pending.field.type === 'password' && (
|
|
108
|
+
<button type="button" className={styles.pwToggle} onClick={() => setReveal(show => !show)}>
|
|
109
|
+
{reveal ? 'Hide' : 'Show'}
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</label>
|
|
114
|
+
)}
|
|
70
115
|
<div className={styles.confirmActions}>
|
|
71
116
|
<button type="button" className={styles.confirmCancel} onClick={() => settle(false)}>
|
|
72
117
|
{pending.cancelLabel ?? 'Cancel'}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import styles from './page.module.css'
|
|
4
|
+
import type { IosBrowser } from './pwa'
|
|
5
|
+
|
|
6
|
+
const ShareArt = () => (
|
|
7
|
+
<svg className={styles.installArt} viewBox="0 0 160 104" aria-hidden="true">
|
|
8
|
+
<rect x="38" y="4" width="84" height="96" rx="13" fill="var(--nc-ink-2)" stroke="var(--nc-line-2)" />
|
|
9
|
+
<rect x="48" y="16" width="46" height="5" rx="2.5" fill="var(--nc-line-2)" />
|
|
10
|
+
<rect x="48" y="28" width="64" height="4" rx="2" fill="var(--nc-line-1)" />
|
|
11
|
+
<rect x="48" y="37" width="54" height="4" rx="2" fill="var(--nc-line-1)" />
|
|
12
|
+
<rect x="48" y="46" width="60" height="4" rx="2" fill="var(--nc-line-1)" />
|
|
13
|
+
<rect x="39" y="73" width="82" height="26" rx="11" fill="var(--nc-ink-3)" />
|
|
14
|
+
<g stroke="var(--nc-fg-3)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none" opacity=".55">
|
|
15
|
+
<path d="M55 82l-4 4 4 4" />
|
|
16
|
+
<path d="M65 82l4 4-4 4" />
|
|
17
|
+
<path d="M99 80h8v12h-8z" />
|
|
18
|
+
<path d="M111 81h6v10h-6z" />
|
|
19
|
+
</g>
|
|
20
|
+
<g stroke="var(--nc-violet-400)" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
21
|
+
<path d="M74 85v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6" />
|
|
22
|
+
<path d="M80 79v10" />
|
|
23
|
+
<path d="M77 82l3-3 3 3" />
|
|
24
|
+
</g>
|
|
25
|
+
<circle cx="80" cy="86" r="12" fill="none" stroke="var(--nc-violet-400)" strokeWidth="1.4" opacity=".55" />
|
|
26
|
+
</svg>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const SheetArt = () => (
|
|
30
|
+
<svg className={styles.installArt} viewBox="0 0 160 104" aria-hidden="true">
|
|
31
|
+
<rect x="10" y="10" width="140" height="84" rx="13" fill="var(--nc-ink-2)" stroke="var(--nc-line-2)" />
|
|
32
|
+
<rect x="68" y="17" width="24" height="4" rx="2" fill="var(--nc-line-2)" />
|
|
33
|
+
<g>
|
|
34
|
+
<rect x="18" y="28" width="124" height="18" rx="8" fill="var(--nc-ink-3)" />
|
|
35
|
+
<rect x="26" y="33" width="9" height="9" rx="2.5" fill="var(--nc-line-2)" />
|
|
36
|
+
<rect x="42" y="35" width="44" height="4" rx="2" fill="var(--nc-line-2)" />
|
|
37
|
+
</g>
|
|
38
|
+
<g>
|
|
39
|
+
<rect x="18" y="50" width="124" height="22" rx="8" fill="none" stroke="var(--nc-violet-400)" strokeWidth="1.6" />
|
|
40
|
+
<g stroke="var(--nc-violet-400)" strokeWidth="1.7" strokeLinecap="round" fill="none">
|
|
41
|
+
<rect x="26" y="56" width="10" height="10" rx="2.5" />
|
|
42
|
+
<path d="M31 58.5v5" />
|
|
43
|
+
<path d="M28.5 61h5" />
|
|
44
|
+
</g>
|
|
45
|
+
<rect x="44" y="59" width="66" height="4" rx="2" fill="var(--nc-violet-400)" />
|
|
46
|
+
</g>
|
|
47
|
+
<g opacity=".5">
|
|
48
|
+
<rect x="26" y="79" width="9" height="9" rx="2.5" fill="var(--nc-line-2)" />
|
|
49
|
+
<rect x="42" y="81" width="38" height="4" rx="2" fill="var(--nc-line-2)" />
|
|
50
|
+
</g>
|
|
51
|
+
</svg>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const HomeArt = () => (
|
|
55
|
+
<svg className={styles.installArt} viewBox="0 0 160 104" aria-hidden="true">
|
|
56
|
+
<rect x="38" y="4" width="84" height="96" rx="13" fill="var(--nc-ink-2)" stroke="var(--nc-line-2)" />
|
|
57
|
+
<g fill="var(--nc-line-1)">
|
|
58
|
+
<rect x="50" y="20" width="18" height="18" rx="5" />
|
|
59
|
+
<rect x="72" y="20" width="18" height="18" rx="5" />
|
|
60
|
+
<rect x="94" y="20" width="18" height="18" rx="5" />
|
|
61
|
+
<rect x="72" y="50" width="18" height="18" rx="5" />
|
|
62
|
+
<rect x="94" y="50" width="18" height="18" rx="5" />
|
|
63
|
+
</g>
|
|
64
|
+
<rect x="50" y="50" width="18" height="18" rx="5" fill="var(--nc-violet-400)" />
|
|
65
|
+
<g stroke="var(--nc-ink-1)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
66
|
+
<rect x="54" y="56" width="10" height="7" rx="1.5" />
|
|
67
|
+
<path d="M54 57.5l5 3.5 5-3.5" />
|
|
68
|
+
</g>
|
|
69
|
+
<rect x="52" y="71" width="14" height="3" rx="1.5" fill="var(--nc-violet-400)" opacity=".6" />
|
|
70
|
+
<g fill="var(--nc-line-1)" opacity=".7">
|
|
71
|
+
<rect x="74" y="71" width="14" height="3" rx="1.5" />
|
|
72
|
+
<rect x="96" y="71" width="14" height="3" rx="1.5" />
|
|
73
|
+
<rect x="52" y="41" width="14" height="3" rx="1.5" />
|
|
74
|
+
<rect x="74" y="41" width="14" height="3" rx="1.5" />
|
|
75
|
+
<rect x="96" y="41" width="14" height="3" rx="1.5" />
|
|
76
|
+
</g>
|
|
77
|
+
</svg>
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
export function InstallGuide({ browser, appName }: { browser: IosBrowser; appName: string }) {
|
|
81
|
+
return (
|
|
82
|
+
<div className={styles.installGuide}>
|
|
83
|
+
{browser === 'other' && (
|
|
84
|
+
<p className={styles.installWarn}>
|
|
85
|
+
You are not in Safari. On iPhone and iPad only Safari can add a real app to the Home Screen — Chrome,
|
|
86
|
+
Firefox and Edge save a bookmark that reopens inside the browser and cannot receive notifications. Open
|
|
87
|
+
this page in Safari, then follow the steps below.
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<ol className={styles.installSteps}>
|
|
92
|
+
<li className={styles.installStep}>
|
|
93
|
+
<ShareArt />
|
|
94
|
+
<div>
|
|
95
|
+
<strong>Tap Share.</strong>
|
|
96
|
+
<p className={styles.settingsNote}>
|
|
97
|
+
It is the square with an arrow coming out of the top, in the bar at the bottom of Safari. On an iPad
|
|
98
|
+
it sits in the top bar instead.
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</li>
|
|
102
|
+
<li className={styles.installStep}>
|
|
103
|
+
<SheetArt />
|
|
104
|
+
<div>
|
|
105
|
+
<strong>Choose Add to Home Screen.</strong>
|
|
106
|
+
<p className={styles.settingsNote}>
|
|
107
|
+
Scroll the share sheet down past the row of apps. The entry has a plus inside a square next to it.
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
</li>
|
|
111
|
+
<li className={styles.installStep}>
|
|
112
|
+
<HomeArt />
|
|
113
|
+
<div>
|
|
114
|
+
<strong>Tap Add.</strong>
|
|
115
|
+
<p className={styles.settingsNote}>
|
|
116
|
+
{appName} Mail lands on your Home Screen and opens in its own window, with no address bar. You can
|
|
117
|
+
rename it on this screen before you confirm.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</li>
|
|
121
|
+
</ol>
|
|
122
|
+
|
|
123
|
+
<p className={styles.settingsNote}>
|
|
124
|
+
Notifications only reach the Home Screen copy. iOS will not deliver them to a page open in a browser tab,
|
|
125
|
+
so add it here first and then turn notifications on inside the app.
|
|
126
|
+
</p>
|
|
127
|
+
<p className={styles.settingsNote}>
|
|
128
|
+
There is no one-tap install button on iPhone because Apple gives websites no way to ask — the share sheet
|
|
129
|
+
is the only route, on every browser.
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|