Improve support and technical references

This commit is contained in:
rosetta
2026-07-31 05:06:37 -07:00
parent 33d4574583
commit 8b2af4db62
6 changed files with 717 additions and 181 deletions

View File

@@ -28,3 +28,47 @@
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
line-height: 1.7; line-height: 1.7;
} }
.callout {
margin: 18px 0 26px;
border: 1px solid light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-9));
border-left: 3px solid var(--mantine-color-blue-6);
background: light-dark(var(--mantine-color-blue-0), rgba(25, 113, 194, 0.12));
}
.vectorGroup {
margin: 18px 0 24px;
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.vectorValue {
padding: 14px 0;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.vectorValue code {
display: block;
margin-top: 7px;
padding: 10px 12px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
font-size: 11px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
}
.marker {
padding: 1px 3px;
background: light-dark(var(--mantine-color-blue-1), rgba(25, 113, 194, 0.24));
color: inherit;
}
.result {
margin: 18px 0;
padding: 12px 14px;
border-left: 3px solid var(--mantine-color-blue-6);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-2));
font-size: var(--mantine-font-size-sm);
font-weight: 650;
}

View File

@@ -1,4 +1,4 @@
import { Anchor, Code, List, Table, Text, Title } from '@mantine/core'; import { Anchor, Box, Code, List, Paper, Table, Text, Title } from '@mantine/core';
import { import {
LegalList, LegalList,
LegalPage, LegalPage,
@@ -9,12 +9,35 @@ import { SEO } from '../components/SEO/SEO';
import classes from './EncryptionPage.module.css'; import classes from './EncryptionPage.module.css';
const ATTACHMENT_LAYOUT = [ const ATTACHMENT_LAYOUT = [
['Magic', '4 bytes', 'ASCII RAC2 (52 41 43 32)'], ['Magic', '4 bytes', 'ASCII RAC1 (52 41 43 31)'],
['Salt', '16 bytes', 'Random per attachment encryption session'], ['Salt', '16 bytes', 'Random per attachment encryption session'],
['IV', '12 bytes', 'Random for every encrypted chunk'], ['IV', '12 bytes', 'Random for every encrypted chunk'],
['Ciphertext + tag', 'Variable', 'AES-256-GCM output and authentication tag'], ['Ciphertext + tag', 'Variable', 'AES-256-GCM output and authentication tag'],
]; ];
const MESSAGE_TEST_VECTOR = {
plaintextHex: '48656c6c6f20526f7365747461',
contentKey: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
nonce: '202122232425262728292a2b2c2d2e2f3031323334353637',
ciphertextAndTag: '553c21a7141746e44bdb24c9299d28c1e78d59d10231cdfe5d9aa1d200',
recipientPrivate: '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20',
recipientPublic:
'0484bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b08cc43d63b2859d469fee15f31c9edb5324266e6fd0407e87382d60fc4511acd8',
ephemeralPrivate: '2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40',
ephemeralPublic:
'04207bba70bc66309baa582a6ac120fd52d68026c51f6326f8ccedcbd2c1b7eb828c18ff7dbee879a4335a05294dea1e99e251f4b3e3b020b507f87064993fb202',
sharedPoint:
'04f40bb548d147b2c3cdcf635658c3fca87a3c1f85c7ac006e9b9e80d4dc70d84de0be123a86ba5e43cadc1364b8e4ea9542e63a1a8ad1a50263c6080826ef2357',
wrappingKey: 'f40bb548d147b2c3cdcf635658c3fca87a3c1f85c7ac006e9b9e80d4dc70d84d',
wrappingIv: '4142434445464748494a4b4c4d4e4f50',
messageSecret:
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3031323334353637',
wrappedSecret:
'44651a40b3dd1ceecbb8f2acfd8229e60a6554fbfaea9838142e898c89d18ede34cc3328ddb3cab3eea77008120228ba4518a0b9b8022a510b79565b806fd208',
envelope:
'NDE0MjQzNDQ0NTQ2NDc0ODQ5NGE0YjRjNGQ0ZTRmNTA6NDQ2NTFhNDBiM2RkMWNlZWNiYjhmMmFjZmQ4MjI5ZTYwYTY1NTRmYmZhZWE5ODM4MTQyZTg5OGM4OWQxOGVkZTM0Y2MzMzI4ZGRiM2NhYjNlZWE3NzAwODEyMDIyOGJhNDUxOGEwYjliODAyMmE1MTBiNzk1NjViODA2ZmQyMDg6MDQyMDdiYmE3MGJjNjYzMDliYWE1ODJhNmFjMTIwZmQ1MmQ2ODAyNmM1MWY2MzI2ZjhjY2VkY2JkMmMxYjdlYjgyOGMxOGZmN2RiZWU4NzlhNDMzNWEwNTI5NGRlYTFlOTllMjUxZjRiM2UzYjAyMGI1MDdmODcwNjQ5OTNmYjIwMg==',
};
function TechnicalCode({ children }: { children: string }) { function TechnicalCode({ children }: { children: string }) {
return ( return (
<Code block className={classes.code}> <Code block className={classes.code}>
@@ -23,6 +46,17 @@ function TechnicalCode({ children }: { children: string }) {
); );
} }
function TechnicalValue({ label, value }: { label: string; value: string }) {
return (
<Box className={classes.vectorValue}>
<Text fw={650} size="xs" c="dimmed">
{label}
</Text>
<Code block>{value}</Code>
</Box>
);
}
export function EncryptionPage() { export function EncryptionPage() {
return ( return (
<> <>
@@ -112,7 +146,140 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
</LegalText> </LegalText>
</LegalSection> </LegalSection>
<LegalSection title="3. Attachment encryption"> <LegalSection title="3. Reproducible example: Hello Rosetta">
<Paper className={classes.callout} radius="sm" p="md">
<Text size="sm" lh={1.7}>
<strong>Test vector.</strong> The values below were calculated with the
same XChaCha20-Poly1305, secp256k1 ECDH, and AES-CBC operations used by
the client. Fixed keys and IVs make the result reproducible; they are
deliberately unsafe for production and must never be reused.
</Text>
</Paper>
<Title order={3} className={classes.subheading}>
Sender: encode and encrypt the message
</Title>
<LegalText>
The UTF-8 string <Code>Hello Rosetta</Code> occupies 13 bytes. The sender
would normally generate the content key and nonce randomly. This example
substitutes fixed sequences so that every value can be checked exactly.
</LegalText>
<Box className={classes.vectorGroup}>
<TechnicalValue label="Plaintext (UTF-8)" value="Hello Rosetta" />
<TechnicalValue
label="Plaintext bytes (hex)"
value={MESSAGE_TEST_VECTOR.plaintextHex}
/>
<TechnicalValue
label="Content key · 32 bytes (test only)"
value={MESSAGE_TEST_VECTOR.contentKey}
/>
<TechnicalValue
label="XChaCha20 nonce · 24 bytes (test only)"
value={MESSAGE_TEST_VECTOR.nonce}
/>
<TechnicalValue
label="XChaCha20-Poly1305 ciphertext + 16-byte authentication tag (hex)"
value={MESSAGE_TEST_VECTOR.ciphertextAndTag}
/>
</Box>
<LegalText>
The result contains 13 encrypted content bytes followed by the 16-byte
Poly1305 authentication tag.{' '}
<Text component="span" className={classes.marker}>
Any change to either part causes authenticated decryption to fail.
</Text>
</LegalText>
<Title order={3} className={classes.subheading}>
Sender: protect the message key for the recipient
</Title>
<LegalText>
The 32-byte content key and 24-byte nonce are joined into one logical
56-byte message secret. An ephemeral secp256k1 key pair and the recipient
public key produce the shared ECDH point. Its 32-byte x-coordinate becomes
the AES key that wraps the message secret.
</LegalText>
<Box className={classes.vectorGroup}>
<TechnicalValue
label="Recipient private key (test only)"
value={MESSAGE_TEST_VECTOR.recipientPrivate}
/>
<TechnicalValue
label="Recipient public key · uncompressed"
value={MESSAGE_TEST_VECTOR.recipientPublic}
/>
<TechnicalValue
label="Ephemeral private key (test only)"
value={MESSAGE_TEST_VECTOR.ephemeralPrivate}
/>
<TechnicalValue
label="Ephemeral public key · uncompressed"
value={MESSAGE_TEST_VECTOR.ephemeralPublic}
/>
<TechnicalValue
label="ECDH shared point"
value={MESSAGE_TEST_VECTOR.sharedPoint}
/>
<TechnicalValue
label="AES wrapping key · shared-point x-coordinate"
value={MESSAGE_TEST_VECTOR.wrappingKey}
/>
<TechnicalValue
label="Message secret · content key || nonce"
value={MESSAGE_TEST_VECTOR.messageSecret}
/>
<TechnicalValue
label="AES-CBC IV · 16 bytes (test only)"
value={MESSAGE_TEST_VECTOR.wrappingIv}
/>
<TechnicalValue
label="Wrapped message secret · AES-CBC + PKCS#7 (hex)"
value={MESSAGE_TEST_VECTOR.wrappedSecret}
/>
<TechnicalValue
label="Transmitted key envelope · Base64(ivHex:cipherHex:ephemeralPublicHex)"
value={MESSAGE_TEST_VECTOR.envelope}
/>
</Box>
<Title order={3} className={classes.subheading}>
Recipient: recover and decrypt
</Title>
<LegalList>
<List.Item>
Base64 decoding separates the wrapping IV, wrapped message secret, and
ephemeral public key.
</List.Item>
<List.Item>
ECDH with the recipient private key and the transmitted ephemeral
public key produces the same shared point and wrapping key.
</List.Item>
<List.Item>
AES-CBC decryption and PKCS#7 removal recover the exact 56-byte message
secret shown above.
</List.Item>
<List.Item>
The first 32 bytes become the XChaCha20 key and the remaining 24 bytes
become its nonce. Poly1305 verifies the encrypted content before it is
accepted.
</List.Item>
</LegalList>
<TechnicalCode>{`recovered shared point matches: true
recovered message secret: ${MESSAGE_TEST_VECTOR.messageSecret}
XChaCha20-Poly1305 decrypt: 48656c6c6f20526f7365747461
UTF-8 decode: Hello Rosetta`}</TechnicalCode>
<Text className={classes.result}>
Authenticated plaintext recovered: Hello Rosetta
</Text>
<LegalText>
In a real message, the content key, nonce, ephemeral private key, and
AES-CBC IV are newly generated. Therefore the ciphertext and envelope will
differ even when the plaintext and recipient are identical.
</LegalText>
</LegalSection>
<LegalSection title="4. Attachment encryption">
<LegalText> <LegalText>
Files, images, audio, and video are encrypted before upload. Binary data is Files, images, audio, and video are encrypted before upload. Binary data is
split into independent chunks so that large files can be streamed, retried, split into independent chunks so that large files can be streamed, retried,
@@ -127,7 +294,7 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
data before plaintext is returned. data before plaintext is returned.
</LegalText> </LegalText>
<TechnicalCode> <TechnicalCode>
RAC2 || salt[16] || iv[12] || AES-256-GCM-ciphertext-and-tag RAC1 || salt[16] || iv[12] || AES-256-GCM-ciphertext-and-tag
</TechnicalCode> </TechnicalCode>
<Table.ScrollContainer minWidth={560}> <Table.ScrollContainer minWidth={560}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}> <Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
@@ -155,7 +322,7 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
</LegalText> </LegalText>
</LegalSection> </LegalSection>
<LegalSection title="4. Groups and compatibility format"> <LegalSection title="5. Groups and compatibility format">
<LegalText> <LegalText>
A group uses a shared group secret available to its members. Group messages A group uses a shared group secret available to its members. Group messages
do not carry a new recipient envelope for every member. The current group do not carry a new recipient envelope for every member. The current group
@@ -170,12 +337,12 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
→ Base64(iv) : Base64(ciphertext)`}</TechnicalCode> → Base64(iv) : Base64(ciphertext)`}</TechnicalCode>
<LegalText> <LegalText>
This compatibility format provides confidentiality but does not include an This compatibility format provides confidentiality but does not include an
AEAD authentication tag. Binary attachments use the newer RAC2 AES-GCM AEAD authentication tag. Binary attachments use the RAC1 AES-GCM
format described above. format described above.
</LegalText> </LegalText>
</LegalSection> </LegalSection>
<LegalSection title="5. Account keys and local storage"> <LegalSection title="6. Account keys and local storage">
<LegalText> <LegalText>
The recovery phrase is expanded to a BIP39 seed. SHA-256 of that seed The recovery phrase is expanded to a BIP39 seed. SHA-256 of that seed
becomes the 32-byte secp256k1 private key, and the client derives the becomes the 32-byte secp256k1 private key, and the client derives the
@@ -189,7 +356,7 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
</LegalText> </LegalText>
</LegalSection> </LegalSection>
<LegalSection title="6. Security boundaries"> <LegalSection title="7. Security boundaries">
<LegalText> <LegalText>
End-to-end encryption protects direct-message content and binary attachment End-to-end encryption protects direct-message content and binary attachment
contents. It does not hide sender and recipient identifiers, timestamps, contents. It does not hide sender and recipient identifiers, timestamps,
@@ -205,7 +372,7 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
</LegalText> </LegalText>
</LegalSection> </LegalSection>
<LegalSection title="7. Terminology and references"> <LegalSection title="8. Terminology and references">
<LegalList> <LegalList>
<List.Item> <List.Item>
<strong>AEAD:</strong> authenticated encryption that provides <strong>AEAD:</strong> authenticated encryption that provides

View File

@@ -11,6 +11,11 @@
white-space: pre; white-space: pre;
} }
.comparisonTable {
margin: 18px 0 24px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.docsLink { .docsLink {
display: inline-block; display: inline-block;
margin-top: 44px; margin-top: 44px;
@@ -24,25 +29,6 @@
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9)); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
} }
.hero {
max-width: 760px;
padding: 48px 0 44px;
}
.hero h1 {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: clamp(32px, 5vw, 44px);
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.08;
}
.heroDescription {
margin-top: var(--mantine-spacing-md);
font-size: 16px;
line-height: 1.7;
}
.stats { .stats {
margin: 0 0 52px; margin: 0 0 52px;
} }
@@ -94,7 +80,6 @@
display: grid; display: grid;
gap: var(--mantine-spacing-sm); gap: var(--mantine-spacing-sm);
margin-bottom: 16px; margin-bottom: 16px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
} }
.schemaSection { .schemaSection {
@@ -196,10 +181,6 @@
padding-top: 28px; padding-top: 28px;
} }
.hero {
padding-top: 38px;
}
.docsGrid { .docsGrid {
gap: 28px; gap: 28px;
} }

View File

@@ -383,6 +383,153 @@ field_id:uint8 | wire_type:uint8 | [length:uint16] | value
</LegalText> </LegalText>
</LegalSection> </LegalSection>
<LegalSection title="How an RCC YAML layer is structured">
<LegalText>
RCC reads versioned files named <Code>layer.N.yml</Code>. The top-level
<Code> version</Code> identifies the protocol layer, while
<Code> description</Code> records its purpose. Definitions are divided into
enumerations, reusable types, and packets. The YAML is a build-time schema;
it is not transmitted with every network request.
</LegalText>
<Code block className={classes.protoCode}>
{`version: 3
description: Add avatars and more CDN safety
enums:
HandshakeStage:
type: int8
values:
- id: 0
name: COMPLETED
- id: 1
name: NEED_DEVICE_VERIFICATION
types:
NetworkDevice:
fields:
- id: 1
name: networkStatus
type: NetworkStatus
- id: 2
name: deviceName
type: string
packets:
Handshake:
id: 0
fields:
- id: 1
name: publicKey
type: string
- id: 6
name: handshakeStage?
type: HandshakeStage`}
</Code>
<LegalList>
<List.Item>
An enum declares its integer wire type and the stable decimal ID of each
value.
</List.Item>
<List.Item>
A type is a reusable TLV structure. It has fields but no packet ID of
its own.
</List.Item>
<List.Item>
A packet has a two-byte packet ID. Each field has its own one-byte ID,
name, and primitive, enum, or reusable type.
</List.Item>
<List.Item>
A question mark at the end of a field name makes that field optional;
the suffix is removed from the generated property name. The field is
omitted from the wire payload when no value is present.
</List.Item>
<List.Item>
A <Code>[]</Code> suffix declares a list. RCC serializes a list by
repeating the same field ID for every item.
</List.Item>
<List.Item>
YAML comments become generated API documentation. RCC also checks type
references, required fields, and stable field-ID use between layers.
</List.Item>
</LegalList>
</LegalSection>
<LegalSection title="From schema to codec">
<LegalText>
RCC converts every definition into a model and a codec. Required fields are
checked before encoding and after decoding. Enum names remain readable in
application code, but only their numeric values are written to the network.
The server build can retain codecs for several layers while a client build
uses the current layer.
</LegalText>
<Code block className={classes.protoCode}>
{`# RCC schema
Result:
id: 2
fields:
- id: 1
name: resultCode
type: ResultCode
# Generated codec behavior
packet id 2
writer.writeInt8(fieldId: 1, value: ResultCode.SUCCESS /* 0 */)
reader.getInt8(fieldId: 1) -> ResultCode.SUCCESS`}
</Code>
</LegalSection>
<LegalSection title="Wire-size example compared with JSON">
<LegalText>
For a <Code>Result</Code> packet whose value is
<Code> ResultCode.SUCCESS</Code>, the complete RCC/TLV payload is five
bytes. The packet ID occupies two bytes; the field ID, wire type, and
<Code> int8</Code> value occupy one byte each.
</LegalText>
<Code block className={classes.protoCode}>
{`00 02 | 01 01 00
^^^^^ ^^^^^^^^
packet field 1 · INT8 · SUCCESS (0)`}
</Code>
<Table.ScrollContainer minWidth={560}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.comparisonTable}>
<Table.Thead>
<Table.Tr>
<Table.Th>Encoding</Table.Th>
<Table.Th>Payload</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>TLV reduction</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>RCC / TLV</Table.Td>
<Table.Td><Code>00 02 01 01 00</Code></Table.Td>
<Table.Td>5 bytes</Table.Td>
<Table.Td>—</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>JSON with enum name</Table.Td>
<Table.Td><Code>{'{"resultCode":"SUCCESS"}'}</Code></Table.Td>
<Table.Td>24 bytes</Table.Td>
<Table.Td>79%</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>JSON with numeric enum</Table.Td>
<Table.Td><Code>{'{"resultCode":0}'}</Code></Table.Td>
<Table.Td>16 bytes</Table.Td>
<Table.Td>69%</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<LegalText>
This is a deliberately small packet and excludes transport framing and
compression. The percentage varies with the data: long strings and files
dominate their field names, while small control packets receive the largest
relative benefit from numeric IDs and binary values.
</LegalText>
</LegalSection>
<Anchor component={Link} to="/proto/docs" className={classes.docsLink}> <Anchor component={Link} to="/proto/docs" className={classes.docsLink}>
See docs of PROTO → See docs of PROTO →
</Anchor> </Anchor>
@@ -425,23 +572,6 @@ export function ProtoDocsPage() {
<SiteHeader /> <SiteHeader />
<main className={classes.page}> <main className={classes.page}>
<Container> <Container>
<Anchor component={Link} to="/" size="sm">
Rosetta
</Anchor>
<Text component="span" c="dimmed" size="sm">
{' '}
/ Proto / Docs
</Text>
<Box className={classes.hero}>
<Title order={1}>Protocol documentation</Title>
<Text c="dimmed" className={classes.heroDescription}>
Complete protocol reference generated from the RCC layer schemas. Browse
packet definitions, types, enumerations, field IDs, optional values,
comments, and linked types.
</Text>
</Box>
<Box className={classes.docsGrid}> <Box className={classes.docsGrid}>
<aside className={classes.sidebar}> <aside className={classes.sidebar}>
<Stack gap="lg"> <Stack gap="lg">
@@ -575,7 +705,7 @@ export function ProtoDocsPage() {
</aside> </aside>
<Box className={classes.documentation}> <Box className={classes.documentation}>
<Paper withBorder radius="sm" p="lg" className={classes.filters}> <Box className={classes.filters}>
<TextInput <TextInput
value={search} value={search}
onChange={(event) => setSearch(event.currentTarget.value)} onChange={(event) => setSearch(event.currentTarget.value)}
@@ -586,7 +716,7 @@ export function ProtoDocsPage() {
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{resultCount} definitions in layer {layer.version} {resultCount} definitions in layer {layer.version}
</Text> </Text>
</Paper> </Box>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" className={classes.stats}> <SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" className={classes.stats}>
{[ {[

View File

@@ -1,72 +1,179 @@
.intro { .page {
margin-top: 36px; min-height: 70vh;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
font-size: var(--mantine-font-size-md);
line-height: 1.7;
} }
.searchArea { .hero {
margin-top: 28px; position: relative;
overflow: visible;
background: light-dark(var(--mantine-color-blue-0), rgba(25, 113, 194, 0.16));
} }
.problemField textarea { .heroInner {
padding-top: 16px; position: relative;
padding-left: 48px; min-height: 390px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); padding-top: 76px;
font-size: var(--mantine-font-size-md); padding-bottom: 88px;
line-height: 1.65; }
.heroContent {
position: relative;
z-index: 2;
}
.heroContent h1 {
color: light-dark(var(--mantine-color-dark-9), var(--mantine-color-white));
font-size: clamp(34px, 6vw, 48px);
font-weight: 600;
letter-spacing: -0.035em;
}
.heroDescription {
max-width: 580px;
font-size: 16px;
}
.searchWrap {
position: relative;
width: min(100%, 680px);
margin-top: 24px;
}
.searchInput input {
height: 64px;
padding-inline: 58px;
border-color: transparent;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: 0 12px 38px rgba(25, 113, 194, 0.16);
font-size: 17px;
}
.searchInput input:focus {
border-color: var(--mantine-color-blue-5);
} }
.suggestions { .suggestions {
margin-top: 8px; position: absolute;
padding: 14px; z-index: 10;
top: calc(100% + 8px);
right: 0;
left: 0;
padding: 8px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
} }
.suggestion { .suggestion {
display: block; display: grid;
padding: 12px 10px; grid-template-columns: auto minmax(0, 1fr);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); gap: 12px;
align-items: start;
padding: 13px 12px;
border-radius: var(--mantine-radius-sm);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.suggestion:first-child { .suggestion svg {
border-top: 0; margin-top: 2px;
color: var(--mantine-color-blue-6);
} }
.suggestion:hover { .suggestion:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
text-decoration: none; text-decoration: none;
} }
.issuesSection, .decorativeIcon {
.contact { position: absolute;
margin-top: 48px; z-index: 1;
padding-top: 36px; color: var(--mantine-color-blue-6);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); opacity: 0.62;
box-shadow: var(--mantine-shadow-sm);
} }
.issuesSection > h2, .iconOne {
.contact > h2 { top: 62px;
font-size: 22px; left: 5%;
font-weight: 650; transform: rotate(-9deg);
} }
.issueList { .iconTwo {
margin-top: 20px; top: 215px;
left: 12%;
transform: rotate(8deg);
}
.iconThree {
top: 70px;
right: 6%;
transform: rotate(10deg);
}
.iconFour {
right: 14%;
bottom: 64px;
transform: rotate(-7deg);
}
.content {
padding-top: 88px;
padding-bottom: 104px;
}
.content h2 {
font-size: 26px;
font-weight: 600;
}
.sectionDescription {
max-width: 620px;
} }
.issue { .issue {
scroll-margin-top: 100px; scroll-margin-top: 96px;
padding: 26px 0; border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.issue:first-child {
border-top: 0;
} }
.issue h3 { .issue h3 {
font-size: 17px; font-size: 18px;
font-weight: 650; font-weight: 650;
} }
.contact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
margin-top: 72px;
padding: 32px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
}
@media (max-width: 48em) {
.heroInner {
min-height: 360px;
padding-top: 64px;
}
.decorativeIcon {
opacity: 0.28;
}
.iconTwo,
.iconFour {
display: none;
}
.content {
padding-top: 72px;
padding-bottom: 80px;
}
.contact {
align-items: flex-start;
flex-direction: column;
margin-top: 56px;
padding: 26px 22px;
}
}

View File

@@ -1,9 +1,34 @@
import { Anchor, Box, Button, Paper, Stack, Text, Textarea, Title } from '@mantine/core'; import {
import { IconMail, IconSearch } from '@tabler/icons-react'; ActionIcon,
Anchor,
Box,
Button,
Card,
Container,
Group,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconBook2,
IconHelpCircle,
IconLifebuoy,
IconMail,
IconMessageQuestion,
IconSearch,
IconShieldQuestion,
IconX,
} from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import knownIssues from '../../known-issues.json'; import knownIssues from '../../known-issues.json';
import { LegalPage } from '../components/LegalPage/LegalPage';
import { SEO } from '../components/SEO/SEO'; import { SEO } from '../components/SEO/SEO';
import { SiteFooter } from '../components/SiteFooter/SiteFooter';
import { SiteHeader } from '../components/SiteHeader/SiteHeader';
import classes from './SupportPage.module.css'; import classes from './SupportPage.module.css';
export function SupportPage() { export function SupportPage() {
@@ -30,7 +55,7 @@ export function SupportPage() {
}) })
.filter(({ score }) => score > 0) .filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score) .sort((left, right) => right.score - left.score)
.slice(0, 4); .slice(0, 5);
}, [normalizedQuery]); }, [normalizedQuery]);
return ( return (
@@ -41,98 +66,180 @@ export function SupportPage() {
canonical="https://rosetta.im/support/" canonical="https://rosetta.im/support/"
keywords="Rosetta support, known issues, account recovery, messaging help" keywords="Rosetta support, known issues, account recovery, messaging help"
/> />
<LegalPage eyebrow="Help centre" title="Support"> <SiteHeader />
<Text className={classes.intro}> <main className={classes.page}>
Describe the problem in your own words. Rosetta will suggest a relevant known <Box component="section" className={classes.hero}>
issue while you type. The text is processed only in this page and is not sent <Container className={classes.heroInner}>
to the support service. <ThemeIcon
className={`${classes.decorativeIcon} ${classes.iconOne}`}
size={50}
radius="xl"
variant="white"
aria-hidden="true"
>
<IconHelpCircle size={26} />
</ThemeIcon>
<ThemeIcon
className={`${classes.decorativeIcon} ${classes.iconTwo}`}
size={44}
radius="xl"
variant="white"
aria-hidden="true"
>
<IconMessageQuestion size={22} />
</ThemeIcon>
<ThemeIcon
className={`${classes.decorativeIcon} ${classes.iconThree}`}
size={54}
radius="xl"
variant="white"
aria-hidden="true"
>
<IconLifebuoy size={27} />
</ThemeIcon>
<ThemeIcon
className={`${classes.decorativeIcon} ${classes.iconFour}`}
size={42}
radius="xl"
variant="white"
aria-hidden="true"
>
<IconShieldQuestion size={21} />
</ThemeIcon>
<Stack align="center" gap="sm" className={classes.heroContent}>
<Text fw={700} size="sm" c="blue.8">
Rosetta Support
</Text>
<Title order={1}>How can we help?</Title>
<Text c="blue.9" ta="center" className={classes.heroDescription}>
Search account access, messaging, media, privacy, and security help.
</Text> </Text>
<Box className={classes.searchArea}> <Box className={classes.searchWrap}>
<Textarea <TextInput
value={query} value={query}
onChange={(event) => setQuery(event.currentTarget.value)} onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={18} />} leftSection={<IconSearch size={21} />}
leftSectionProps={{ style: { alignItems: 'flex-start', paddingTop: 16 } }} rightSection={
placeholder="For example: my attachment is still sending…" query ? (
aria-label="Describe your Rosetta problem" <ActionIcon
minRows={6} variant="subtle"
autosize color="gray"
maxRows={10} onClick={() => setQuery('')}
radius="sm" aria-label="Clear support search"
className={classes.problemField} >
<IconX size={18} />
</ActionIcon>
) : null
}
placeholder="Describe your problem"
aria-label="Search Rosetta support"
size="xl"
radius="xl"
className={classes.searchInput}
/> />
{normalizedQuery && ( {normalizedQuery && (
<Paper withBorder radius="sm" className={classes.suggestions}> <Paper
<Text fw={700} size="xs" tt="uppercase" c="dimmed"> withBorder
Suggested issues shadow="lg"
</Text> radius="md"
className={classes.suggestions}
>
{suggestions.length > 0 ? ( {suggestions.length > 0 ? (
<Stack gap={0} mt="xs"> <Stack gap={0}>
{suggestions.map(({ issue }) => ( {suggestions.map(({ issue }) => (
<Anchor <Anchor
key={issue.id} key={issue.id}
href={`#${issue.id}`} href={`#${issue.id}`}
onClick={() => setQuery('')}
className={classes.suggestion} className={classes.suggestion}
> >
<IconHelpCircle size={18} />
<Box>
<Text fw={600} size="sm"> <Text fw={600} size="sm">
{issue.title} {issue.title}
</Text> </Text>
<Text c="dimmed" size="xs" mt={3}> <Text c="dimmed" size="xs" mt={3}>
{issue.summary} {issue.summary}
</Text> </Text>
</Box>
</Anchor> </Anchor>
))} ))}
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed" size="sm" mt="xs"> <Box p="md">
No matching known issue. You can contact support below. <Text fw={600} size="sm">
No matching known issue
</Text> </Text>
<Text c="dimmed" size="xs" mt={4}>
Try fewer words or contact the support team below.
</Text>
</Box>
)} )}
</Paper> </Paper>
)} )}
</Box> </Box>
</Stack>
</Container>
</Box>
<Box component="section" className={classes.issuesSection}> <Container className={classes.content}>
<Box component="section">
<Group gap="sm">
<ThemeIcon variant="light" radius="xl">
<IconBook2 size={18} />
</ThemeIcon>
<Title order={2}>Known issues</Title> <Title order={2}>Known issues</Title>
<Text c="dimmed" size="sm" mt="xs" lh={1.7}> </Group>
<Text c="dimmed" mt="xs" className={classes.sectionDescription}>
Common situations and the information needed to resolve them. Common situations and the information needed to resolve them.
</Text> </Text>
<Box className={classes.issueList}> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg" mt="xl">
{knownIssues.map((issue) => ( {knownIssues.map((issue) => (
<Box component="article" id={issue.id} key={issue.id} className={classes.issue}> <Card
component="article"
id={issue.id}
key={issue.id}
withBorder
radius="md"
padding="xl"
className={classes.issue}
>
<Title order={3}>{issue.title}</Title> <Title order={3}>{issue.title}</Title>
<Text c="dimmed" size="sm" mt={6} lh={1.7}> <Text c="dimmed" size="sm" mt="sm" lh={1.65}>
{issue.summary} {issue.summary}
</Text> </Text>
<Text size="sm" mt="md" lh={1.75}> <Text size="sm" mt="lg" lh={1.7}>
{issue.solution} {issue.solution}
</Text> </Text>
</Box> </Card>
))} ))}
</Box> </SimpleGrid>
</Box> </Box>
<Box component="section" className={classes.contact}> <Paper component="section" radius="lg" className={classes.contact}>
<Title order={2}>Contact support</Title> <Box>
<Text c="dimmed" size="sm" mt="xs" lh={1.7}> <Title order={2}>Still need help?</Title>
Include your operating system, Rosetta version, approximate time of the <Text c="dimmed" size="sm" mt="xs" lh={1.7} maw={620}>
problem, and reproduction steps. Never send a recovery phrase or private Include your operating system, Rosetta version, approximate time,
key. and reproduction steps. Never send a recovery phrase or private key.
</Text> </Text>
</Box>
<Button <Button
component="a" component="a"
href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request" href="mailto:support@rosetta.im?subject=Rosetta%20Support%20Request"
leftSection={<IconMail size={17} />} leftSection={<IconMail size={17} />}
variant="default" variant="default"
mt="lg"
> >
support@rosetta.im support@rosetta.im
</Button> </Button>
</Box> </Paper>
</LegalPage> </Container>
</main>
<SiteFooter />
</> </>
); );
} }