diff --git a/Docs/AppAPI/guide b/Docs/AppAPI/guide index 07b4cf9d..3fedfc00 100644 --- a/Docs/AppAPI/guide +++ b/Docs/AppAPI/guide @@ -122,12 +122,23 @@ operation itself declared with `remote=True` — and only through the control plane, which is the one door that knows the difference. An app is not asked to work out whether its caller is at the keyboard. +### An operation that names its own permission + +`app_api.operation(name, doc, params, changes=…, permission="contacts.read")` +says which permission an **app** caller needs; without it the call is the read or +the change of the apps it always was. The name is kept in the declaration (it +travels in the catalogue, so the Internal API page shows it) but is **not** +carried by `declare_all`, which is what an app outside the node declares: a +permission is the node's to name, not a thing a package states about itself. + ## What ships today | app | operation | changes | remote | what it answers | |---|---|---|---|---| | `chat` | `peer` | no | no | pseudo, whether it is a contact, unread count, where the conversation lives | -| `chat` | `contact` | yes | no | add a node to the address book (not a grant, and not a name — that is the node's own, signed) | +| `chat` | `contact` | yes | no | add a node to the address books through the contacts app (not a grant, and not a name — that is the node's own, signed); refused with a named permission when chat was not allowed to write | +| `contacts` | `books` `list` `names` `search` `find` | no | no | the address books — each declares `contacts.read` | +| `contacts` | `add` `remove` | yes | no | save or take out a node — each declares `contacts.write` | | `fleet` | `relation` | no | **yes** | both directions of the ledger: rights held over that node, rights it holds here, requests pending either way | | `fleet` | `enrol` | yes | no | ask a node for the right to manage it | | `fleet` | `request` | yes | no | ask a node already managed for extra rights | diff --git a/Docs/AppPermissions/guide b/Docs/AppPermissions/guide index 8e6f0612..edbc8084 100644 --- a/Docs/AppPermissions/guide +++ b/Docs/AppPermissions/guide @@ -39,6 +39,9 @@ manifest that asks for something else is told which name it got wrong. | `readstate.trust` | sensitive | trust anchors, witnesses, membership, signing keys held (never the keys) | | `readstate.updates` | sensitive | releases, packages, the installed set, transfers | | `readstate.apps` | sensitive | apps, their state, and their read-only operations | +| `contacts` | sensitive | **everything below** — the address books ([`../Apps/contacts`](../Apps/contacts)) | +| `contacts.read` | sensitive | the books, the contacts in them and the names they were given; searching them | +| `contacts.write` | sensitive | adding a node to the books, taking one out | | `control` | dangerous | **everything below** — what an operator at the console can do | | `control.node` | dangerous | ping, speed test, forget, retry, restart, rename | | `control.network` | dangerous | probing, punching, listening, discovery, multi-link | @@ -51,7 +54,8 @@ manifest that asks for something else is told which name it got wrong. | `modding` | dangerous | standing in front of the node's own operations — before, after, or instead of them | **A parent covers its children.** Granting `readstate` grants all nine reads; -granting `readstate.logs` grants that one. Taking a parent back takes its children +granting `readstate.logs` grants that one. Reading is not writing: `contacts.read` +does not give `contacts.write`. Taking a parent back takes its children with it: a page that unticks `readstate` and leaves `readstate.logs` ticked has said two contradictory things, and the narrower one is kept. @@ -256,6 +260,15 @@ declared a manifest: attached now (with its own token or only the shared one), installed with a `nmesh.json`, or remembered from before; it is where an app's connector token is read (this machine only) and where an app is forgotten. +An operation another app declares can name its own permission +(`app_api.operation(..., permission="contacts.read")`): a call **from an app** +is then answered from that name (`app_perms.app_call_need`) instead of the +blanket `readstate.apps` / `control.apps`. A name that is not on the tree is +ignored, so naming one cannot be a way to need nothing. Chat uses it to reach the +address books, and when a person has not answered yet it shows the question the +way a phone does: *Allow*, *Read only*, *Not now* (the last is remembered in that +browser only). + *Apps → Internal API*: every endpoint, what it does, the permission an app needs, and the MCP tool it becomes. diff --git a/Docs/Apps/chat b/Docs/Apps/chat index 492ef063..13f24364 100644 --- a/Docs/Apps/chat +++ b/Docs/Apps/chat @@ -4,7 +4,8 @@ NMesh's reference messaging app, built on the data connector. A real end-to-end messenger: **profiles** (name, bio, avatar), 1:1 **conversations** and **groups**, **replies**, **editing**, **deletion**, **emoji reactions**, **delivered/read receipts**, a **typing indicator**, **file and media sharing** -(chunked + verified, images previewed), **finding people by name** (the node's +(chunked + verified, images previewed), **voice messages** (Opus), **finding +people by name** (the node's signed pseudos, whole or partial), plus the **real-time stream** of timestamped frames — the primitive a voice/video call would use. Everything stays **E2E encrypted**; @@ -144,10 +145,29 @@ sender; a peer's `EDIT`/`DELETE` only ever alters its own messages. A profile (bio/avatar) is **learned** from the peer; the **name is not in it** — that comes from the node's signed claim, and the node_id stays the real identity. +## Voice messages + +The microphone button (shown when the text box is empty and the browser can +record Opus) records with `MediaRecorder` as **Opus** in WebM or Ogg +(`audio/webm;codecs=opus`, `audio/ogg;codecs=opus`, 32 kbit/s, five minutes at +most) and sends it as an ordinary file transfer — chunked, verified, rate +limited like any other. There is no new wire type: a voice message is a file +whose name says so, `voice-.webm|ogg`. The name is the sender's, +so the receiving node decides (`chat_web.voice_length`): the pattern must match +exactly, the length is 1 ms to 10 minutes and the file 1 byte to 8 MiB, the size +must be a plain integer — anything else is shown as the plain file it is, and a +non-Opus file with a borrowed name is the browser's to refuse. The page fetches +the bytes through its authenticated `GET /api/chat/file` (so it follows the node +being driven), plays them from a blob URL (at most 24 held) and shows a play +button, a seek bar and the length. A browser that cannot record Opus simply has +no microphone button, and still plays what it receives. + ## The screen Three panes on one grid: the **conversation list**, the **thread**, and the node -card of whoever you are talking to. Which of them is on screen is a single +card of whoever you are talking to. The list has two tabs: **Chats** and +**Contacts** (the mirror above, one tap to start a conversation, a link to the +Contacts page). Which of them is on screen is a single attribute on the shell — `data-view="list" | "conv" | "peer"` — so at any width there is exactly one answer to "what am I looking at", and the panes cannot contradict each other. @@ -158,6 +178,12 @@ conversation. Below 860 the screen shows one pane at a time and the list is the navigation — no drawer and no hamburger, the same stance the console takes with its tab bar. +A touch screen (`pointer:coarse`) gets larger targets, decided by the pointer +and not by a user-agent string; the composer swaps its microphone for the send +button as soon as there is text, and shows only the recorder (cancel, time, +send) while recording, so a tap cannot send half a sentence typed beside it. +Every sentence is read from the catalogue — `Docs/I18n/guide`. + Everything is prefixed `ch-`. The design system (`src/webassets/ui.py`) owns the generic names, and this page used to redefine `.msg` — quietly changing a shared component for itself alone. The prefix is what stops that. @@ -223,9 +249,23 @@ real data. writes it through the console (`POST /api/pseudo`), the node signs a claim for it, and chat mirrors it back with the rest. Chat's own profile is the **bio and avatar**, which it does announce to contacts (`PROFILE`). -- **Contacts**: `add_contact` / `remove_contact` (a node id — no name is passed, - because what a node is called is its own to say). Adding a contact announces - my profile to them. +- **Contacts**: chat has **no address book of its own**. The people it shows are + the [contacts app's](contacts), read and written through the permission a + person gave chat (`contacts.read` / `contacts.write`, `Docs/AppPermissions/guide`). + `ChatBridge.contacts_access()` is one of `write`, `read`, `denied` or + `unavailable`, and the page is built for each: *denied* shows a permission card + (*Allow*, *Read only*, *Not now*) and the contacts chat already had stay on + screen as they were — a node that has not answered loses nothing; the first + time chat may **write**, those contacts move into the default book (once, + `migrated`); afterwards the list chat renders from is a **mirror** of the books + (`ChatState.replace_contacts`), carrying the name its owner gave each node + (`label`), refreshed on every poll. `add_contact(id)` saves the node in the + books (named from the directory) and announces my profile to them; refused it + raises `PermissionError(permission)`, which the console returns as `403 + {"error":"permission","permission":…}` so the page asks the question instead of + failing. Starting a chat does **not** save the person — a conversation and an + address book are two decisions — and any conversation shows **Add to + contacts** while the node is in no book. - **Groups**: `create_group(name, members)` mints a `group_id`, adds me to the roster and sends a `GROUP_INVITE` to each member. `send_group_text` **fans** the message out to every member (members never relay → no loop, no @@ -237,7 +277,8 @@ real data. unsigned pseudo in a peer-to-peer profile is a peer telling us what to call it, which is exactly how impersonation works. That field is gone, and so are the old `DIR_QUERY` / `DIR_REPLY` messages it fed. -- **Search by name** — `search_pseudo(query, wide=…)`, which is the node's own +- **Search by name** — `search_pseudo(query, wide=…)`: saved contacts first + (found by the name their owner gave them, `match = -1`), then the node's own ranked search (exact, then prefix, then a word inside the name, then anywhere; case- and accent-insensitive), merged with `find_by_pseudo` over the contacts already on this screen. `wide=True` also asks the network for the exact name, @@ -245,7 +286,9 @@ real data. [`Docs/Pseudos/guide`](../Pseudos/guide). All of this is drivable from the console's `/chat` sub-page (identity + copying -the id, contacts, groups, search). See `Docs/WebConsole/guide`. +the id, contacts, groups, search). See `Docs/WebConsole/guide`. `/chat#c/` +opens (or starts) the conversation with that node; the Contacts page and the +node card link to it. ## Audio calls (`src/apps/call.py`) diff --git a/Docs/Apps/contacts b/Docs/Apps/contacts new file mode 100644 index 00000000..8db4b895 --- /dev/null +++ b/Docs/Apps/contacts @@ -0,0 +1,174 @@ +# The contacts application + +`src/apps/contacts.py`, `contacts_state.py`, `contacts_web.py`, +`contacts_link.py` · page `src/webassets/contacts.py` (console `/contacts`) + +The address books of a node. A **contact** is a name a person chose and the +nodes it answers to; a **book** is a named list of contacts. A node keeps as +many books as it likes (family, work, the ones other people shared), and **every +other app asks for them** — chat keeps no list of its own beyond a copy it draws +the screen from. + +It is a built-in app like chat: it runs on the data connector under its own app +id, keeps its books in its own encrypted drawer (never in the clear on disk), and +is on by default. Turn it off from *Apps* and `/contacts` and `/api/contacts/*` +stop existing. + +## Names are somebody's own word + +What a node is called by *itself* is a signed claim the node holds +([`../Pseudos/guide`](../Pseudos/guide)). What a **contact** is called is what the +person who owns the book wrote. The two are kept apart: the page shows the node's +own name beside the one the person chose, never in place of it, and a nickname +cannot make a stranger look like somebody else (a name always travels with the +node id). + +## The permission + +Android's model: a person decides, per app, whether it may touch the contacts. + +| Permission | Level | Allows | +|---|---|---| +| `contacts` | sensitive | everything below | +| `contacts.read` | sensitive | list the books and their contacts, search by name or node id, ask who a node is | +| `contacts.write` | sensitive | add a node as a contact, take one out | + +Nothing is granted by asking. An app that asks for them says why on the +permissions page (chat: "it shows the people you know by the names you gave +them" / "it adds a person to your address book when you press *Add to +contacts*"). Reading does not let an app write, taking back `contacts` takes +back its children, and a client holding only the connector's shared token never +gets either (`Docs/AppPermissions/guide`). + +The operations are declared with the permission they need +(`app_api.operation(..., permission="contacts.read")`), and the control plane +answers an **app** caller from that — so the check is made where the call is +made, not left to the app being asked: + +| Operation | Needs | Changes | Answers | +|---|---|---|---| +| `books` | `contacts.read` | no | id, name, colour, count, default/writable/shared flags | +| `list` | `contacts.read` | no | the contacts of one book (or all), without their notes | +| `names` | `contacts.read` | no | every node in the books and the name its owner gave it | +| `search` | `contacts.read` | no | contacts matching a name, a label, or the start of a node id | +| `find` | `contacts.read` | no | the contacts that answer to a node | +| `add` | `contacts.write` | yes | save a node (named from the directory when no name is given) | +| `remove` | `contacts.write` | yes | take a node out of one book or all of them | + +None of them is `remote`: a console managing this node reaches the books only +with its `apps` grant, and `/api/contacts/` is on the same list as +`/api/chat/` (`_NEEDS_APPS`). The note a person wrote beside a contact never +leaves through the API. + +### How an app uses it + +`src/apps/contacts_link.py`: + +```python +link = ContactsLink(host, perms, my_app_id_hex) +link.access() # "write" | "read" | "denied" | "unavailable" +link.call("find", node=hex) # Unavailable | Denied(permission) | AppAPIError +``` + +The host and the permission book are looked up on every call, so a contacts app +stopped a second ago is not reachable through a reference the link kept. A refused +app gets a **refusal** (`Denied` naming the permission), never a thinner answer — +so it can ask the person instead of showing an empty list that looks true. + +## Finding somebody on a network of thousands + +Search is two questions asked in order: the books first (instant, nothing leaves +the node), then the directory (`wide`), which is the node's own ranked name +search and asks the network for an exact name. Saved contacts come first in +chat's search (`match = -1`), found by the name their owner gave them as well as +the one the node signs. Ranking is `pseudo.fold` + `rank_folded` — the same +rules as the directory, so a name found here and one found there sort alike. + +## The data + +`ContactsState` is the data and its rules, no sockets. Every collection is hard +bounded: + +| Limit | Value | +|---|---| +| books | 32 | +| contacts per book (tombstones included) | 1000 | +| nodes per contact | 8 | +| name / label / note | 64 / 32 / 512 characters | +| members of a shared book | 32 | +| invitations waiting for a human | 16 in all, 2 per sender, 14 days | + +A book is one value of the node's drawer (256 KiB at most); a book that no +longer fits is refused with `full` rather than truncated. Deleted contacts leave +a tombstone for 90 days so the deletion reaches nodes that were offline; they +are the first thing to go when room is needed. + +## Sharing a book with another node + +A book is shared by **invitation**, and an invitation is not trusted: a node that +offers a book costs the other a line in a queue until a human accepts. A contact +called "Bank" planted in somebody's address book is a social-engineering attack, +so the only way into a book is a person saying yes. Roles are `read` and `write`. + +**The owner numbers.** The node that made the book gives changes a sequence +number; a member that may write sends its edits to the owner, which orders them +and passes them on. Each entry carries a version `[counter, writer]`, ordered by +counter and then by writer id (last write wins), so every replica settles on the +same entry without a clock anyone has to believe. If the owner is gone for good +the copies stay readable and, where writable, editable — they stop following each +other, nothing more. The owner can also let a member go (`revoke` detaches its +copy, which becomes the member's own book). + +Wire format inside each E2E `DATA` payload: one type byte, then a JSON object. + +| Type | Sent | Meaning | +|---|---|---| +| `OFFER` (1) | owner → node | "here is a book, if you want it" | +| `ACCEPT` / `DECLINE` (2, 3) | node → owner | the human's answer | +| `LEAVE` (4) | member → owner | "I no longer follow this book" | +| `PULL` (5) | member → owner | "what changed since this sequence?" | +| `PUSH` (6) | either way | contacts, newest wins (20 per page) | +| `ACK` (7) | owner → member | "I have these of yours" | +| `REVOKE` (8) | owner → member | "this book is no longer shared with you" | + +Everything is validated before it is looked at: a message over 40 000 bytes, one +that is not what its type says, a push to a book that was not accepted, an edit +from a reader, an entry whose version jumps past the bound — all dropped, with +the sender reported (`AppGuard`: invitations 4/60 s, answers 20/10 s, pulls and +pushes 30 and 60/10 s). A pull for a book this node does not own is answered +with nothing (not even "no such book"), and `REVOKE` is only ever sent to a node +that really is not a member. A round runs every 300 s (pulls, the pushes that +failed, invitations not yet answered, at most 12 times). + +## The page (`/contacts`) + +Three panes on one grid — the **books**, the **contacts** of the open book, one +**contact** — and a single attribute, `data-view="books|list|detail"`, says which +is on screen. Wide enough (over 1000 px) shows all three; narrower shows one at a +time with a back button. The page never asks which device it is on. + +- books: colour, "private / shared by you / shared with you", pending + **invitations** with Accept / Decline; +- contacts: search within the book, favourites first, add by **name from the + network** or by pasting a node id; +- editor: name, up to eight nodes (each with a label), note, favourite, move to + another book, **Message** (opens `/chat#c/`); +- share dialog: invite by name or id, role, who follows, take back; +- import / export as **vCard** (`X-NMESH-NODE` carries the node ids; any other + address book reads the rest). An import is capped at 600 000 characters of text and 1000 entries (the request carrying it at 2 MiB). + +Every sentence is read from the catalogue (`t()`), and a refusal from the node +travels as a stable code (`why`: `no_book`, `read_only`, `no_contact`, `empty`, +`full`, `bad_node`, `not_owner`, `self`, `gone`) that the page turns into its own +words. + +## Console routes + +| Method | Path | | +|---|---|---| +| GET | `/api/contacts/state` | `?book=…` → books, invitations, the book's contacts, names (sectioned like chat's feed) | +| GET | `/api/contacts/people` | `?q=…&wide=0|1` → saved contacts first, then the directory | +| GET | `/api/contacts/export` | `?book=…` → a `.vcf` download | +| POST | `/api/contacts/` | `save` `delete` `move` `book` `share` `unshare` `answer` `people` `import` | + +All behind the console session; the page and its assets are public, like chat's. diff --git a/Docs/Architecture/README.md b/Docs/Architecture/README.md index 0f6ef9f9..7244d19f 100644 --- a/Docs/Architecture/README.md +++ b/Docs/Architecture/README.md @@ -64,7 +64,8 @@ is therefore safe to accept from strangers. | `apps/mcp.py` | **The MCP app**: an MCP server (Streamable HTTP, JSON-RPC, stdlib) whose tools are generated from the control catalogue, the apps' operations and the page routes — filtered by what *it* was granted, reached through the internal API like any app. Loopback and a bearer token by default. See [`../Apps/mcp`](../Apps/mcp). | | `apps/fleet_links.py` | The mesh map past one node's own eyes: what the machines an operator manages say they are connected to, held **while it is still true**. A freshness book rather than a history — a machine that has not confirmed in 45 seconds has no links on the map, not old ones — replacing rather than merging, bounded on every axis, and in memory only. | | `apps/fleet_logs.py` | The logs of the machines an operator manages, kept on the operator's node: one bounded, compressed ring **per machine** (never one pool — a chatty machine must not push out a quiet one's log), a bounded number of them, and a collection policy per node with a fleet-wide default. Ordered by *our* clock, never by the time the far machine supplied. | -| `apps/chat*.py` | The built-in chat app: messages/files/stream (`chat.py`), the social layer of contacts/groups (`chat_state.py`), the console UI (`chat_web.py`). Names are mirrored from the node, never carried in a chat message. | +| `apps/chat*.py` | The built-in chat app: messages/files/stream (`chat.py`), the social layer of groups and a **mirror** of the address books (`chat_state.py`), the console UI and voice messages (`chat_web.py`). Names are mirrored from the node, never carried in a chat message. Chat keeps no contact list of its own: it asks the contacts app, within the permission it was given. | +| `apps/contacts*.py` | The built-in address books: books and contacts with hard bounds (`contacts_state.py`), sharing a book with other nodes by invitation and keeping the copies in step — owner-numbered, last-write-wins by `[counter, writer]` (`contacts.py`), the console side, vCard and the declared API (`contacts_web.py`), and how another app reaches the books through `contacts.read` / `contacts.write` (`contacts_link.py`). See [`Docs/Apps/contacts`](../Apps/contacts). | | `app_package.py` | Content-addressed packages + a **signed release** (deployment: app_id bound to the ML-DSA author, a signed `ts` for version ordering). | | `app_catalog.py` | App store: the network catalogue (signed releases, gossiped, anti-rollback) + a local registry of installed apps. | | `app_storage.py` | A local per-app store (the "drawer"): key→value encrypted at rest (AES-256-GCM, a per-app key derived from the identity), isolated by `app_id`, bounded. | diff --git a/Docs/Architecture/gotchas.md b/Docs/Architecture/gotchas.md index 6b0f6648..f298e00b 100644 --- a/Docs/Architecture/gotchas.md +++ b/Docs/Architecture/gotchas.md @@ -2801,3 +2801,12 @@ the code, and each looked right from reading it. `ROUTER.onChange` set beside the panels, which loads the Docker panel when it is opened and refreshes the Logs one, never ran (BUGSVULNS 119). Call `ROUTER.start()` with no argument where the handler is set elsewhere. +- **A `bool` is an `int`.** `chat_web.voice_length(name, True)` measured a + one-byte file because `isinstance(True, int)`; the size a peer declares is + checked with `type(size) is int`. +- **A built-in app with no book at start.** `ContactsApp.start()` creates the + default book (`ensure_default`); a page reading the state before the first + write otherwise found nothing to save a contact into. +- **A coroutine handed to a loop that is not there.** `ChatBridge._run` closes + the coroutine when the loop is `None`; left unawaited it warned at exit and + hid the real refusal. diff --git a/Docs/I18n/guide b/Docs/I18n/guide index 7d3da6f2..9e957792 100644 --- a/Docs/I18n/guide +++ b/Docs/I18n/guide @@ -62,6 +62,17 @@ and markup that carries its words as data, translated in one pass at load: ``` +A page's `` can name a catalogue key for the note the context bar shows +when the node being driven has not granted the page its apps +(`data-ctx-local-key="chat.ctx_local"`); `data-ctx-local` stays for a sentence +written in place. + +`tests/test_i18n.py` also reads the **chat and contacts pages** for every key +they ask for (`data-i18n`, `data-i18n-attr`, `t("…")`, both arms of +`t(flag ? "a" : "b")`) and requires each in all seven languages, and every +refusal code the contacts bridge can send (`contacts.err.`). A page that +builds a key from a code lists the codes there. + A key nobody defined shows as itself — ugly on purpose. `applyI18n(root)` translates markup a page built later. diff --git a/Docs/WebConsole/guide b/Docs/WebConsole/guide index 657f4063..5ed0a48e 100644 --- a/Docs/WebConsole/guide +++ b/Docs/WebConsole/guide @@ -276,7 +276,7 @@ never get wrong. console. - **The apps follow the node exactly as far as it granted.** A managed node - opens `/api/chat/` to a console holding its **`apps`** grant and `/api/fleet/` + opens `/api/chat/` and `/api/contacts/` to a console holding its **`apps`** grant and `/api/fleet/` to one holding **`full`**, and refuses them otherwise (somebody else's conversations are not part of managing their machine unless they said so; a managed node is not a jump host unless they said so). `/api/remote/` is this @@ -803,9 +803,13 @@ now answers `{"error": …}` with the same status. | POST | `/api/chat/read` | `{conv}` — marks read (sends the read receipt) | | POST | `/api/chat/typing` | `{conv, active}` — typing indicator | | POST | `/api/chat/profile`| `{bio?, avatar?:b64}` — updates my profile (the **name** is the node's, see `/api/pseudo`) | -| POST | `/api/chat/contact`| `{op:add\|remove, id}` | +| POST | `/api/chat/contact`| `{op:add\|remove, id}` — through the contacts app; `403 {error:"permission", permission}` when chat was not allowed | | POST | `/api/chat/group` | `{op:create, name, members[]}` / `{op:remove, id}` | | POST | `/api/chat/search` | `{pseudo}` → the node's ranked name search, merged with this screen's contacts | +| GET | `/api/contacts/state` | `?book=` → books, invitations, one book's contacts and names (sectioned like chat's feed) | +| GET | `/api/contacts/people` | `?q=&wide=` → saved contacts first, then the directory | +| GET | `/api/contacts/export` | `?book=` → a vCard download | +| POST | `/api/contacts/` | `save delete move book share unshare answer people import` — see [`../Apps/contacts`](../Apps/contacts); `import` accepts a 2 MiB body | | GET | `/api/pseudo` | this node's name, id and the length limit (`pseudo.get`); `?q=…` searches the names this node holds (`pseudo.search`), `&wide=1` asks the directory instead (`pseudo.lookup`, **local only** — a Kademlia round does not fit the relay) | | POST | `/api/pseudo` | `{pseudo}` → renames this node (empty removes the name); signs a fresh claim, announces it, and records it in the configuration file when there is one (the node keeps the name across a restart either way — see [`../Pseudos/guide`](../Pseudos/guide)) | | GET | `/api/fleet/state` | `?since=N` → managed nodes, enrolment notifications, log, capabilities, machine facts. `&proto=2&have=name:rev,…` asks for the **sectioned** shape and gets back only what moved (`src/console_feed.py`); without `proto` it is the flat snapshot it has always been | @@ -908,6 +912,9 @@ The node ships a **default chat app**, plugged into the mesh like any other app (per tab, never on disk) for explicit use; opened cold with no valid session, the page asks for the console password and logs in by itself. A plain **refresh** no longer asks for anything. +- the **`/contacts`** sub-page is the address books (`Docs/Apps/contacts`), served + the same way: the markup is public, every `/api/contacts/*` call needs the + session, and with the app off neither exists (404). - the `/api/chat/*` endpoints require the **same token** as the rest of the console. With no chat app attached, neither `/chat` nor `/api/chat/*` exists (404) and the app list is empty. diff --git a/TEST.md b/TEST.md index b93256f8..ad53d97c 100644 --- a/TEST.md +++ b/TEST.md @@ -536,6 +536,42 @@ tests/ │ test_console_fleet.py, chat's social half in test_chat_web.py — sections │ named, one that moved coming back, one that did not staying away, and the │ messages travelling beside them whatever the claim says +├── test_contacts_state.py — the address books' data +│ and rules: every collection bounded (books, contacts, nodes, names, +│ notes, members, invitations), an entry with nothing in it refused, the +│ newest version winning by [counter, writer] whatever order they arrive, +│ tombstones kept for a deletion to travel and dropped first when room is +│ needed, a book that no longer fits refused rather than truncated, search +│ by name / label / note / the start of an id, vCard in and out with hostile +│ cards, and a state file that lies cut down to what it can prove +├── test_contacts_sync.py — two and three nodes keeping +│ one book in step over a wire that hands each message straight to the other +│ node's handler: nothing enters a book that was not accepted, a reader +│ cannot write, a message that is not what it says is dropped and its sender +│ reported, an unknown book is answered with silence, revoke detaches the +│ copy, and a dozen shapes of garbage never raise +├── test_contacts_integration.py — chat over the books: the +│ permission tree (sensitive, a read half and a write half, a parent covering +│ its children, a shared-token client never getting it), the link's four +│ states and its refusal naming the permission, the old contacts moving only +│ when chat may write, the books' name winning on screen, a saved name found +│ first, and an operation that names a permission nobody has being no way +│ to need nothing +├── test_console_contacts.py — the console's side: the +│ page served only while the app runs and its API never without a session, +│ a refusal arriving as a stable code, export / import round trip with the +│ import body capped, nonsense bodies never a 5xx, and chat's missing +│ permission arriving as a 403 naming it +├── test_i18n.py — one catalogue per language: the +│ same keys and parameters everywhere, the plural forms each language needs, +│ the browser's plural rule and parameter filling held to the Python ones by +│ running both, and every key the chat and contacts pages ask for — in their +│ markup, their script, and the refusal codes the node can send — defined in +│ all seven +├── test_chat_web.py (voice messages) — a voice message is a file +│ whose name says so, and the name is the sender's: the exact pattern, the +│ length and size bounds, a size that is not a plain integer (a boolean is +│ one in Python), a borrowed name on a huge file or a picture staying what it is ├── test_console_auth.py — console credential: │ the password never stored, a salt per credential, a corrupt file or an │ unknown algorithm refused, an outsized input rejected before hashing, diff --git a/pyproject.toml b/pyproject.toml index 1c629317..ce002b8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends.legacy:build" [project] name = "nmesh" -version = "0.4.118" +version = "0.4.119" license = "GPL-3.0-only" [tool.pytest.ini_options] diff --git a/scripts/nmesh_node.py b/scripts/nmesh_node.py index 044de43a..d40d16fa 100644 --- a/scripts/nmesh_node.py +++ b/scripts/nmesh_node.py @@ -70,12 +70,17 @@ def _count_this_start() -> None: from src.data_connector import DataConnector, ConnectorClient from src.process_launcher import ProcessLauncher from src.app_channel import CHAT_APP_ID -from src.app_registry import (BUILTIN_APPS, FLEET_APP_ID, MCP_APP_ID, AppHost, +from src.app_registry import (BUILTIN_APPS, CONTACTS_APP_ID, FLEET_APP_ID, + MCP_APP_ID, AppHost, AppRegistry) from src.apps import fleet_console from src.apps.chat import ChatApp from src.apps.chat_state import ChatState, DrawerStore from src.apps.chat_web import ChatBridge +from src.apps.contacts import ContactsApp +from src.apps.contacts_link import ContactsLink +from src.apps.contacts_state import ContactsState +from src.apps.contacts_web import ContactsBridge from src.apps.fleet import FleetApp from src.apps.fleet_state import FleetState from src.apps.fleet_web import FleetBridge @@ -84,7 +89,7 @@ def _count_this_start() -> None: from src import config -def _chat_factory(node, connector): +def _chat_factory(node, connector, host=None, perms=None): """Build the chat app on demand (the app host calls this when enabling it). State and message history live in the node's encrypted per-app drawer @@ -103,7 +108,26 @@ async def build(): store = DrawerStore(node.app_storage, CHAT_APP_ID) state = ChatState(store=store) app = ChatApp(client, node_id=node.id, state=state) - return app, ChatBridge(app, store=store) + # The address books are the contacts app's. Chat reaches them through + # the operations that app declares, as far as it was given the + # `contacts` permission — not by being the same program. + link = ContactsLink(host, perms, CHAT_APP_ID.hex()) + return app, ChatBridge(app, store=store, contacts=link) + return build + + +def _contacts_factory(node, connector): + """Build the contacts app on demand: books in its own encrypted drawer, + synced with the nodes they are shared with.""" + async def build(): + client = ConnectorClient(connector.host, connector.port, + connector.token_for(CONTACTS_APP_ID), + CONTACTS_APP_ID, attended=False) # see `_chat_factory` + await client.connect() + store = DrawerStore(node.app_storage, CONTACTS_APP_ID) + state = ContactsState(store=store, me=node.id.raw.hex()) + app = ContactsApp(client, node_id=node.id, state=state) + return app, ContactsBridge(app) return build @@ -595,7 +619,9 @@ async def main() -> None: if connector is not None: host = AppHost(registry, app_storage=node.app_storage, language=lambda: config.language(config_path)) - host.register("chat", _chat_factory(node, connector)) + host.register("contacts", _contacts_factory(node, connector)) + host.register("chat", _chat_factory(node, connector, host=host, + perms=registry.perms)) host.register("fleet", _fleet_factory(node, connector, args.data, local_console)) host.register("mcp", _mcp_factory(node, connector)) diff --git a/src/app_api.py b/src/app_api.py index f02fe2a1..2dea285a 100644 --- a/src/app_api.py +++ b/src/app_api.py @@ -88,7 +88,8 @@ def param(name: str, kind: str, *, required: bool = True, default=None, def operation(name: str, summary: str, params=(), *, changes: bool = False, - remote: bool = False, operator: bool = False) -> dict: + remote: bool = False, operator: bool = False, + permission: str = "") -> dict: """Declare one operation. ``changes`` marks an operation that alters state. It is not a permission — @@ -107,7 +108,14 @@ def operation(name: str, summary: str, params=(), *, changes: bool = False, ``operator`` says only a *person* may call it — never another app, whatever that app was granted. For an answer that is a credential: the MCP app's bearer token is readable by whoever runs the node, and an app that could - read it would hold every permission the MCP app holds.""" + read it would hold every permission the MCP app holds. + + ``permission`` names the permission another *app* must hold to call it + (``contacts.read``), where "may drive the apps" is too wide an answer: an + app that may read the address book should not need to be allowed to + restart the node. It is a name on the tree in :mod:`src.app_perms`, and + only a built-in app can set it — an attached app's declaration goes through + :func:`declare_all`, which does not carry it.""" if not _NAME_RE.match(name): raise AppAPIError(f"bad operation name {name!r}") fields = list(params) @@ -115,7 +123,7 @@ def operation(name: str, summary: str, params=(), *, changes: bool = False, raise AppAPIError("too many parameters") return {"name": name, "summary": summary, "params": fields, "changes": bool(changes), "remote": bool(remote), - "operator": bool(operator)} + "operator": bool(operator), "permission": str(permission or "")} def coerce(field: dict, raw): diff --git a/src/app_perms.py b/src/app_perms.py index 064c0f33..65a1e4f7 100644 --- a/src/app_perms.py +++ b/src/app_perms.py @@ -129,6 +129,17 @@ def _perm(name, title, level, description, children=()): "Apps, their state, and the read-only operations they " "declare."), )), + # -- what a person keeps about the people they know ----------------------- + _perm("contacts", "Use the address books", SENSITIVE, + "Everything below: read the contacts, and change them.", + children=( + _perm("contacts.read", "Read the contacts", SENSITIVE, + "Who is in the address books: names, the nodes they answer " + "to, the notes written beside them."), + _perm("contacts.write", "Change the contacts", SENSITIVE, + "Add, edit and remove contacts in the books this node " + "keeps, and so in every node a book is shared with."), + )), # -- driving it --------------------------------------------------------- _perm("control", "Drive this node", DANGEROUS, "Everything below: what an operator at the console can do.", @@ -612,8 +623,21 @@ def overview(self) -> list: # Anything here makes the plane worth reaching at all: an app holding none of # it is shown an empty catalogue, and its jobs and app calls are refused. -_DRIVING = ("readstate", "control") + tuple( - child for root in ("readstate", "control") for child in children(root)) +_DRIVING = ("readstate", "control", "contacts") + tuple( + child for root in ("readstate", "control", "contacts") for child in children(root)) + + +def app_call_need(declared: dict) -> str: + """The permission an *app* needs to call one operation another app declared. + + An operation names its own (``permission=``, on the built-in apps that + guard something more specific than "the apps"); the rest are a read or a + change of the apps, as they always were. A name that is not on the tree is + ignored: it must not be a way to ask for nothing.""" + named = declared.get("permission") + if isinstance(named, str) and named in _BY_NAME: + return named + return "control.apps" if declared.get("changes") else "readstate.apps" class ControlGate: @@ -674,7 +698,8 @@ def _carried_job(self, app_hex, params, depth): def _carried_app_call(self, app_hex, params): if not isinstance(params, dict) or "app" not in params: return (None if self._book.allows_any( - app_hex, ("readstate.apps", "control.apps")) + app_hex, ("readstate.apps", "control.apps", + "contacts.read", "contacts.write")) else "this app may not call other apps") declared = None if self._find_app_op is not None: @@ -685,7 +710,7 @@ def _carried_app_call(self, app_hex, params): declared = None if declared is None: return None # "no such operation", said by the module - need = "control.apps" if declared.get("changes") else "readstate.apps" + need = app_call_need(declared) if self._book.allows(app_hex, need): return None return f"that operation needs the {need} permission" diff --git a/src/app_registry.py b/src/app_registry.py index a037097b..05c0dd23 100644 --- a/src/app_registry.py +++ b/src/app_registry.py @@ -39,6 +39,7 @@ FLEET_APP_ID = builtin_id("fleet") MCP_APP_ID = builtin_id("mcp") +CONTACTS_APP_ID = builtin_id("contacts") # What every built-in app does through the connector anyway — its own section, # names, reports, a log line. Asked for like any other app asks, so the Apps @@ -62,7 +63,25 @@ "page": "/chat", "app_id": CHAT_APP_ID, "default_enabled": True, - "description": "Messaging, files and calls across the mesh.", + "description": "Messaging, voice messages, files and calls across the mesh.", + "permissions": _BASE + [ + {"name": "contacts.read", + "why": "It shows the people you know by the names you gave them, " + "and finds someone you have already saved."}, + {"name": "contacts.write", + "why": "It adds a person to your address book when you press " + "\"Add to contacts\"."}, + ], + }, + { + "name": "contacts", + "title": "Contacts", + "page": "/contacts", + "app_id": CONTACTS_APP_ID, + "default_enabled": True, + "description": ("Address books: the people you know and the nodes they " + "answer to, kept in as many books as you like, shared " + "with other nodes, and offered to the apps you allow."), "permissions": _BASE, }, { diff --git a/src/apps/chat_state.py b/src/apps/chat_state.py index e9fba101..b0c5c2a2 100644 --- a/src/apps/chat_state.py +++ b/src/apps/chat_state.py @@ -78,6 +78,7 @@ def __init__(self, path: str | None = None, store: DrawerStore | None = None) -> self._store = store self._lock = threading.Lock() self.pseudo = "" + self.migrated = False # legacy contacts now live in the books self.bio = "" self.avatar = b"" # my own avatar thumbnail bytes self.contacts: dict[str, dict] = {} # id_hex -> {pseudo, added, bio} @@ -108,10 +109,12 @@ def _load(self) -> None: if not isinstance(doc, dict): return self.bio = str(doc.get("bio", ""))[:_MAX_BIO] + self.migrated = doc.get("migrated") is True if isinstance(doc.get("contacts"), dict): for k, v in list(doc["contacts"].items())[:_MAX_CONTACTS]: if _is_id(k) and isinstance(v, dict): self.contacts[k] = {"pseudo": normalize_pseudo(str(v.get("pseudo", ""))), + "label": normalize_pseudo(str(v.get("label", ""))), "added": float(v.get("added", 0) or 0), "bio": str(v.get("bio", ""))[:_MAX_BIO]} if isinstance(doc.get("known"), dict): @@ -141,7 +144,7 @@ def _load(self) -> None: def _save_locked(self) -> None: # Our own pseudo is deliberately absent: it lives on the node, which # signs it. Persisting a copy here would only let the two disagree. - doc = {"bio": self.bio, + doc = {"bio": self.bio, "migrated": self.migrated, "contacts": self.contacts, "known": self.known, "groups": self.groups, "avatar_ids": list(self._avatars.keys())} if self._store is not None: @@ -197,6 +200,7 @@ def add_contact(self, id_hex: str) -> bool: self.contacts[id_hex] = { "pseudo": existing.get("pseudo", "") or self.known.get(id_hex, {}).get("pseudo", ""), + "label": existing.get("label", ""), "added": existing.get("added") or time.time(), "bio": existing.get("bio", "") or self.known.get(id_hex, {}).get("bio", ""), } @@ -204,6 +208,30 @@ def add_contact(self, id_hex: str) -> bool: self._save_locked() return True + def replace_contacts(self, labels: dict) -> None: + """Mirror the address books: ``labels`` is every node in them and the + name its owner gave it. Chat keeps no list of its own beyond this copy, + which it needs to render a screen without asking on every frame.""" + with self._lock: + kept: dict[str, dict] = {} + for id_hex, label in list(labels.items())[:_MAX_CONTACTS]: + if not _is_id(id_hex): + continue + before = self.contacts.get(id_hex) or {} + kept[id_hex] = { + "pseudo": before.get("pseudo", "") + or self.known.get(id_hex, {}).get("pseudo", ""), + "label": normalize_pseudo(str(label or "")), + "added": before.get("added") or time.time(), + "bio": before.get("bio", "") or self.known.get(id_hex, {}).get("bio", "")} + if kept == self.contacts and self.migrated: + return + for id_hex in kept: + self.known.pop(id_hex, None) + self.contacts = kept + self.migrated = True + self._save_locked() + def remove_contact(self, id_hex: str) -> bool: with self._lock: if self.contacts.pop(id_hex, None) is None: @@ -313,8 +341,9 @@ def find_by_pseudo(self, pseudo: str) -> list[dict]: with self._lock: for src, kind in ((self.contacts, "contact"), (self.known, "known")): for id_hex, rec in src.items(): - if needle in rec.get("pseudo", "").casefold(): - out.append({"id": id_hex, "pseudo": rec["pseudo"], "kind": kind}) + shown = rec.get("label") or rec.get("pseudo", "") + if needle in shown.casefold() or needle in rec.get("pseudo", "").casefold(): + out.append({"id": id_hex, "pseudo": shown, "kind": kind}) return out def snapshot(self) -> dict: diff --git a/src/apps/chat_web.py b/src/apps/chat_web.py index 45affc03..04b4146d 100644 --- a/src/apps/chat_web.py +++ b/src/apps/chat_web.py @@ -15,6 +15,7 @@ import asyncio import hmac import json +import re import secrets import threading import time @@ -27,6 +28,7 @@ from .. import console_feed from ..http_headers import SECURITY_HEADERS from ..node_id import NodeID +from . import contacts_link from .chat import ( TextMessage, FileReceived, GroupMessage, Receipt, Typing, Edited, Deleted, Reaction, @@ -62,6 +64,25 @@ def _is_image(name: str) -> bool: return name.lower().endswith(_IMAGE_EXTS) +# A voice message is a file whose name says what it is: Opus in a WebM or Ogg +# container, with its length in milliseconds. The name is the sender's, so it is +# checked here and the length is bounded; anything that does not fit is shown +# as the plain file it is. +_VOICE_NAME = re.compile(r"voice-([0-9]{1,7})\.(webm|ogg)") +VOICE_MAX_BYTES = 8 * 1024 * 1024 +VOICE_MAX_MS = 10 * 60 * 1000 + + +def voice_length(name, size) -> int | None: + """Length in milliseconds when ``name`` and ``size`` describe a voice + message this node will play, otherwise ``None``.""" + found = _VOICE_NAME.fullmatch(name) if isinstance(name, str) else None + if found is None or type(size) is not int or not 0 < size <= VOICE_MAX_BYTES: + return None + length = int(found.group(1)) + return length if 0 < length <= VOICE_MAX_MS else None + + class ChatBridge: @@ -92,9 +113,13 @@ class ChatBridge: changes=True), ) - def __init__(self, chat_app, *, peer: NodeID | None = None, store=None) -> None: + def __init__(self, chat_app, *, peer: NodeID | None = None, store=None, + contacts=None) -> None: self._chat = chat_app self._peer = peer + # The address books, reached the way any app reaches them: through the + # contacts app's declared operations and the permission it was given. + self._contacts = contacts self._store = store # DrawerStore | None — persists the feed if set self._loop: asyncio.AbstractEventLoop | None = None self._messages: list = [] # ordered records (trimmed manually) @@ -243,9 +268,14 @@ def _text_rec(self, mid: bytes, text: str, reply: bytes | None) -> dict: "reply": reply.hex() if reply else None, "reactions": {}} def _file_rec(self, mid: bytes, name: str, size: int, reply: bytes | None) -> dict: - return {"mid": mid.hex(), "kind": "image" if _is_image(name) else "file", - "name": name, "size": size, "available": True, - "reply": reply.hex() if reply else None, "reactions": {}} + rec = {"mid": mid.hex(), "kind": "image" if _is_image(name) else "file", + "name": name, "size": size, "available": True, + "reply": reply.hex() if reply else None, "reactions": {}} + length = voice_length(name, size) + if length is not None: + rec["kind"] = "voice" + rec["duration_ms"] = length + return rec def _auto_deliver(self, src: NodeID, mid: bytes, group_id) -> None: # Acknowledge receipt of a 1:1 message so the sender sees a delivered tick. @@ -341,6 +371,7 @@ def _apply_receipt(self, kind: int, mids: list) -> None: def _run(self, coro, timeout: float = _CALL_TIMEOUT): if self._loop is None: + coro.close() raise RuntimeError("bridge not started") return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout=timeout) @@ -490,11 +521,71 @@ def get_avatar(self, id_hex: str) -> bytes | None: def set_profile(self, *, bio=None, avatar=None) -> None: self._run(self._chat.set_profile(bio=bio, avatar=avatar)) + # -- contacts: the books are the contacts app's, chat only asks ---------- + + def contacts_access(self) -> str: + """``"write"``, ``"read"``, ``"denied"`` or ``"unavailable"``: what chat + may do with the address books right now.""" + return self._contacts.access() if self._contacts is not None else "unavailable" + + def sync_contacts(self) -> str: + """Bring the copy chat renders from in step with the books. + + Contacts chat kept before the books existed move into them the first + time chat is allowed to write; until then they are shown as they were, + so a node that has not answered the permission prompt loses nothing.""" + access = self.contacts_access() + if access not in ("read", "write"): + return access + state = self._chat.state + try: + if not state.migrated and state.contacts: + if access != "write": + return access + for id_hex, rec in list(state.contacts.items()): + self._contacts.call("add", node=id_hex, + name=rec.get("label") or rec.get("pseudo") or "") + names = self._contacts.call("names").get("names", {}) + except (contacts_link.Denied, contacts_link.Unavailable, app_api.AppAPIError): + return self.contacts_access() + state.replace_contacts(names if isinstance(names, dict) else {}) + return access + def add_contact(self, id_hex: str) -> bool: - return self._run(self._chat.add_contact(NodeID(bytes.fromhex(id_hex)))) + """Save a node in the address books, then say hello with our profile. + + ``PermissionError`` (naming the permission) when chat was not allowed to + write — the page turns that into the question a person answers.""" + if self._contacts is None: + return self._run(self._chat.add_contact(NodeID(bytes.fromhex(id_hex)))) + pseudo = next((r.get("pseudo", "") for r in + self._chat.state.snapshot()["known"] if r["id"] == id_hex), "") + try: + result = self._contacts.call("add", node=id_hex, name=pseudo) + except contacts_link.Denied as exc: + raise PermissionError(exc.permission or "contacts.write") from None + except (contacts_link.Unavailable, app_api.AppAPIError): + return False + self.sync_contacts() + target = NodeID(bytes.fromhex(id_hex)) + if self._chat.node_id is None or target != self._chat.node_id: + try: + self._run(self._chat.send_profile(target)) + except Exception: + pass + return bool(result.get("added")) def remove_contact(self, id_hex: str) -> bool: - return self._chat.state.remove_contact(id_hex) + if self._contacts is None: + return self._chat.state.remove_contact(id_hex) + try: + removed = self._contacts.call("remove", node=id_hex).get("removed", 0) + except contacts_link.Denied as exc: + raise PermissionError(exc.permission or "contacts.write") from None + except (contacts_link.Unavailable, app_api.AppAPIError): + return False + self.sync_contacts() + return bool(removed) def remove_group(self, gid_hex: str) -> bool: return self._chat.state.remove_group(gid_hex) @@ -512,13 +603,25 @@ def search_pseudo(self, pseudo: str) -> list: the same way. ``wide`` asks the network too, for an exact name whose owner we have never met.""" hits = {h["id"]: h for h in self._chat.state.find_by_pseudo(pseudo)} + if self._contacts is not None: + # People already in the books come first, found by the name their + # owner gave them as well as by the one the node signs. + try: + saved = self._contacts.call("search", query=pseudo[:200], limit=20) + except (contacts_link.Denied, contacts_link.Unavailable, app_api.AppAPIError): + saved = {} + for contact in saved.get("contacts", []): + for node in contact.get("nodes", []): + hits[node["id"]] = {"id": node["id"], "match": -1, "contact": True, + "pseudo": contact.get("name") or node.get("label") or ""} try: for r in self._run(self._chat.search_pseudo(pseudo, wide=True), timeout=_SEARCH_TIMEOUT): nid = r.get("id") if isinstance(nid, str): - hits[nid] = {"id": nid, "pseudo": r.get("pseudo", ""), - "match": r.get("match", 3)} + if nid not in hits or not hits[nid].get("contact"): + hits[nid] = {"id": nid, "pseudo": r.get("pseudo", ""), + "match": r.get("match", 3)} except Exception: pass return sorted(hits.values(), key=lambda h: (h.get("match", 3), @@ -540,7 +643,7 @@ def api_peer(self, node: str) -> dict: return {"node": node, "contact": known, "seen": entry is not None, - "pseudo": (entry or {}).get("pseudo", ""), + "pseudo": (entry or {}).get("label") or (entry or {}).get("pseudo", ""), "has_avatar": bool((entry or {}).get("has_avatar")), "unread": unread, "conversation": "/chat#c/" + node} @@ -549,7 +652,11 @@ def api_contact(self, node: str) -> dict: """Add someone to the address book. Adding a contact grants nothing, and names nothing: what a node is called is its own to say, in a signed claim. This only says that this node is one we care about.""" - return {"added": bool(self.add_contact(node))} + try: + return {"added": bool(self.add_contact(node))} + except PermissionError as exc: + raise app_api.AppAPIError( + "chat was not given the %s permission" % (str(exc) or "contacts")) from None def snapshot(self, since: int, *, have=None, proto: int = 1) -> dict: """What a page reads, in the shape it asked for. @@ -559,6 +666,7 @@ def snapshot(self, since: int, *, have=None, proto: int = 1) -> dict: one message that arrived. Split into sections and handed to :mod:`src.console_feed`, which sends back only what moved. The messages are already read by sequence and travel beside them.""" + access = self.sync_contacts() state = self._chat.state.snapshot() now = time.time() with self._lock: @@ -571,6 +679,7 @@ def snapshot(self, since: int, *, have=None, proto: int = 1) -> dict: "bio": state.get("bio", ""), "has_avatar": state.get("has_avatar", False), "contacts": state["contacts"], + "contacts_access": access, "known": state["known"], "groups": state["groups"], "unread": dict(self._unread), diff --git a/src/apps/contacts.py b/src/apps/contacts.py new file mode 100644 index 00000000..0c83905a --- /dev/null +++ b/src/apps/contacts.py @@ -0,0 +1,473 @@ +""" +Contacts — the address books of this node, and how they follow a person around. + +Runs on the data connector like chat does: its own section of the mesh, its own +encrypted drawer, its own allowances per sender. What it adds is the one thing a +single node cannot do alone — keep an address book on **several nodes** in step, +so the contacts a person made on their laptop are on their phone. + +**Sharing is an invitation, and an invitation is not trusted.** A node that +offers a book to another costs the second one a line in a queue until a human +accepts it; nothing a stranger sends reaches a book that was not accepted. A +contact called "Bank" planted in somebody's address book is a social-engineering +attack, so the only way into a book is a person saying yes. + +**The owner numbers.** One node made the book and is the only one that gives +changes a sequence number; a member that may write sends its edits to the owner, +which orders them and passes them on. That keeps every replica settling on the +same entry (:func:`~src.apps.contacts_state.newer`) without a clock anyone has +to believe. If the owner is gone for good the copies stay readable and, for a +member that could write, editable — they stop following each other, nothing +more, and the owner can hand a member the book by letting it go (``revoke`` +detaches the copy and it becomes that node's own). + +Wire format inside each E2E DATA payload: a one-byte type, then a JSON object. +Every message is bounded and validated before it is looked at; a malformed one +is dropped, never fatal. +""" +from __future__ import annotations + +import asyncio +import json +import time + +from ..accusation import KIND_FLOOD as ABUSE_FLOOD +from ..app_guard import AppGuard, Limit +from ..node_id import NodeID +from . import contacts_state as cs +from .contacts_state import ContactsState + +_OFFER = 0x01 # owner -> node: "here is a book, if you want it" +_ACCEPT = 0x02 # node -> owner: "yes" +_DECLINE = 0x03 # node -> owner: "no" +_LEAVE = 0x04 # member -> owner: "I no longer follow this book" +_PULL = 0x05 # member -> owner: "what changed since this sequence?" +_PUSH = 0x06 # either way: contacts, newest wins +_ACK = 0x07 # owner -> member: "I have these of yours" +_REVOKE = 0x08 # owner -> member: "this book is no longer shared with you" + +MAX_MESSAGE = 40_000 # bytes, before parsing +PAGE_ENTRIES = 20 # contacts in one push +PAGE_BYTES = 30_000 +SYNC_INTERVAL = 300.0 # seconds between rounds of pulls and resends +OFFER_RETRIES = 12 # times an unanswered invitation is sent again +NAMES_REFRESH = 60.0 +MAX_NAMES = 5000 +_MAX_JOBS = 32 + +# What one node may send us in ten seconds. Sized per kind like chat's: an +# invitation is the thing a stranger can send and a correct client sends +# rarely, a pull is a question that costs the owner a page of answers. +_LIMITS = { + "invitations": Limit(4, 60.0, weight=3), + "answers": Limit(20, 10.0, weight=2), + "pulls": Limit(30, 10.0), + "pushes": Limit(60, 10.0), +} +_KINDS = {_OFFER: "invitations", _ACCEPT: "answers", _DECLINE: "answers", + _LEAVE: "answers", _PULL: "pulls", _PUSH: "pushes", _ACK: "pushes", + _REVOKE: "answers"} + + +def _encode(kind: int, body: dict) -> bytes: + return bytes([kind]) + json.dumps(body, ensure_ascii=False, + separators=(",", ":")).encode("utf-8") + + +class ContactsApp: + """Books on this node, kept in step with the nodes they are shared with.""" + + def __init__(self, client, *, node_id: NodeID | None = None, + state: ContactsState | None = None) -> None: + self._client = client + self.node_id = node_id + self.state = state or ContactsState(me=node_id.raw.hex() if node_id else "") + self._task: asyncio.Task | None = None + self._sync_task: asyncio.Task | None = None + self._jobs: set = set() + self.names: dict[str, str] = {} # node id -> the pseudo the node holds + self._names_at = 0.0 + self._guard = AppGuard(client, _LIMITS, kind=ABUSE_FLOOD, spawn=self._spawn) + + # -- lifecycle --------------------------------------------------------- + + async def start(self) -> None: + self.state.ensure_default() + self._task = asyncio.create_task(self._loop()) + self._sync_task = asyncio.create_task(self._sync_loop()) + + async def stop(self) -> None: + for attr in ("_task", "_sync_task"): + task = getattr(self, attr, None) + if task is None: + continue + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + setattr(self, attr, None) + jobs = list(self._jobs) + self._jobs.clear() + for job in jobs: + job.cancel() + if jobs: + await asyncio.gather(*jobs, return_exceptions=True) + await self._client.close() + + def _spawn(self, coro) -> None: + """Run a short job off the receive loop, bounded, cancelled at stop.""" + if len(self._jobs) >= _MAX_JOBS: + coro.close() + return + try: + task = asyncio.create_task(coro) + except RuntimeError: + coro.close() + return + self._jobs.add(task) + task.add_done_callback(self._jobs.discard) + + async def _loop(self) -> None: + while True: + try: + src, payload = await self._client.recv() + except (asyncio.IncompleteReadError, ConnectionError, OSError): + return + try: + await self._dispatch(src, payload) + except Exception: + pass # a malformed message is dropped, the loop goes on + + async def _sync_loop(self) -> None: + await asyncio.sleep(3.0) + while True: + try: + await self.sync_round() + except asyncio.CancelledError: + raise + except Exception: + pass + await asyncio.sleep(SYNC_INTERVAL) + + # -- sending ----------------------------------------------------------- + + async def _send(self, target: str, kind: int, body: dict) -> bool: + if self.node_id is not None and target == self.node_id.raw.hex(): + return False + payload = _encode(kind, body) + if len(payload) > MAX_MESSAGE: + return False + try: + await self._client.send(NodeID(bytes.fromhex(target)), payload) + return True + except Exception: + return False + + def _pages(self, entries: list): + """Split contacts into messages that fit one frame.""" + page, size = [], 0 + for entry in entries: + blob = len(json.dumps(entry, ensure_ascii=False)) + if page and (len(page) >= PAGE_ENTRIES or size + blob > PAGE_BYTES): + yield page + page, size = [], 0 + page.append(entry) + size += blob + if page: + yield page + + async def offer(self, book_id: str, node: str, role: str) -> bool: + """Share ``book_id`` with ``node``: an invitation, not a transfer.""" + if not self.state.share(book_id, node, role): + return False + return await self._offer_to(book_id, node) + + async def _offer_to(self, book_id: str, node: str) -> bool: + meta = self.state.book(book_id) + member = meta["members"].get(node) if meta else None + if meta is None or member is None: + return False + count = sum(1 for e in self.state.entries(book_id)) + return await self._send(node, _OFFER, { + "book": book_id, "name": meta["name"], "color": meta["color"], + "role": member["role"], "count": count}) + + async def revoke(self, book_id: str, node: str) -> bool: + """Stop sharing with ``node``; its copy stays with it, as its own.""" + if not self.state.unshare(book_id, node): + return False + await self._send(node, _REVOKE, {"book": book_id}) + return True + + async def remove_book(self, book_id: str) -> bool: + """Delete a book. An owner lets every member go first; a member says it + is leaving.""" + meta = self.state.delete_book(book_id) + if meta is None: + return False + if meta["role"] == cs.OWNER: + for node in meta["members"]: + await self._send(node, _REVOKE, {"book": book_id}) + else: + await self._send(meta["owner"], _LEAVE, {"book": book_id}) + return True + + async def respond(self, book_id: str, sender: str, accept: bool) -> bool: + """A human's answer to an invitation.""" + offer = self.state.take_offer(book_id, sender) + if offer is None: + return False + if not accept: + await self._send(sender, _DECLINE, {"book": book_id}) + return True + if not self.state.accept_offer(offer): + await self._send(sender, _DECLINE, {"book": book_id}) + return False + await self._send(sender, _ACCEPT, {"book": book_id}) + await self._pull(book_id) + return True + + async def _pull(self, book_id: str) -> None: + meta = self.state.book(book_id) + if meta is None or meta["role"] == cs.OWNER: + return + await self._send(meta["owner"], _PULL, {"book": book_id, "since": meta["synced"]}) + + async def after_change(self, book_id: str, cids=(), *, skip: str = "") -> None: + """Tell the other side about an edit made here, best effort — the next + sync round carries what this misses. ``skip`` is a member that sent the + change and already has it.""" + meta = self.state.book(book_id) + if meta is None: + return + if meta["role"] == cs.OWNER: + rows = self.state.raw_entries(book_id, cids) + for node, member in meta["members"].items(): + if member["state"] == "active" and rows and node != skip: + await self._push_to(node, book_id, rows) + else: + await self._push_dirty(book_id) + + async def _push_to(self, node: str, book_id: str, rows: list, *, + page: bool = False, top: int = 0, more: bool = False) -> None: + pages = list(self._pages(rows)) or [[]] + for i, chunk in enumerate(pages): + last = i == len(pages) - 1 + body = {"book": book_id, "entries": chunk} + if page: + body.update(page=True, top=top, more=more and last) + await self._send(node, _PUSH, body) + + async def _push_dirty(self, book_id: str) -> None: + meta = self.state.book(book_id) + if meta is None or meta["role"] != "write": + return + rows = self.state.dirty_entries(book_id, 200) + if rows: + await self._push_to(meta["owner"], book_id, rows) + + # -- one round of housekeeping ------------------------------------------ + + async def sync_round(self) -> None: + """Ask every owner for what changed, send what is still ours to send, + and say again to anyone who has not answered an invitation.""" + for meta in self.state.books(): + book_id = meta["id"] + if meta["role"] == cs.OWNER: + for node, member in meta["members"].items(): + if member["state"] == "offered" and member["tries"] < OFFER_RETRIES: + self.state.note_try(book_id, node) + await self._offer_to(book_id, node) + else: + await self._pull(book_id) + await self._push_dirty(book_id) + await self.refresh_names() + + async def refresh_names(self, force: bool = False) -> None: + """Re-read what the node calls the nodes in these books.""" + now = time.monotonic() + if not force and self._names_at and now - self._names_at < NAMES_REFRESH: + return + self._names_at = now + resolve = getattr(self._client, "refresh_names", None) + if resolve is None: + return + ids = sorted(self.state.node_ids())[:MAX_NAMES] + if not ids: + return + try: + found = await resolve(ids) + except Exception: + return + for node in ids: + if found.get(node): + self.names[node] = str(found[node])[:64] + else: + self.names.pop(node, None) + while len(self.names) > MAX_NAMES: + self.names.pop(next(iter(self.names))) + + async def name_of(self, node: str) -> str: + """What the node calls ``node``, asked now when it is not already known.""" + known = self.names.get(node) + if known: + return known + resolve = getattr(self._client, "refresh_names", None) + if resolve is None: + return "" + try: + found = await resolve([node]) + except Exception: + return "" + name = found.get(node) if isinstance(found, dict) else None + return str(name)[:64] if name else "" + + async def lookup(self, query: str, *, wide: bool = False) -> list: + """The node's directory, asked for a name (whole or partial).""" + fn = getattr(self._client, "lookup_pseudo", None) + if fn is None: + return [] + try: + rows = await fn(query, wide=wide) + except Exception: + return [] + out = [] + for row in rows[:cs.MAX_RESULTS]: + node, name = row.get("id"), row.get("pseudo") + if cs.is_node(node) and isinstance(name, str): + self.names.setdefault(node, name[:64]) + out.append({"id": node, "pseudo": name[:64], + "match": row.get("match", 3)}) + return out + + async def notify(self, key: str, summary: str) -> None: + fn = getattr(self._client, "notify", None) + if fn is None: + return + try: + await fn(key, summary, level="warn") + except Exception: + pass + + # -- receiving --------------------------------------------------------- + + async def _dispatch(self, src: NodeID, payload: bytes) -> None: + if not payload or len(payload) > MAX_MESSAGE: + return + kind = payload[0] + handler = _HANDLERS.get(kind) + if handler is None: + return + if not self._guard.allow(_KINDS[kind], src): + return + try: + body = json.loads(payload[1:].decode("utf-8")) + except (UnicodeDecodeError, ValueError, RecursionError): + return + if not isinstance(body, dict) or not cs.is_book_id(body.get("book")): + return + await handler(self, src.raw.hex(), body) + + async def _on_offer(self, src: str, body: dict) -> None: + role, name = body.get("role"), body.get("name") + if role not in cs.ROLES or not isinstance(name, str): + return + count = body.get("count") + meta = self.state.book(body["book"]) + if meta is not None: + # A book we already follow: the owner is telling us what we may do. + if meta["role"] != cs.OWNER and meta["owner"] == src: + self.state.set_role(body["book"], role) + return + if self.state.receive_offer(src, body["book"], name, + body.get("color") if isinstance(body.get("color"), str) else "", + role, count if isinstance(count, int) else 0): + self._spawn(self.notify( + "contacts-offer", "A contact book was shared with this node")) + + async def _on_accept(self, src: str, body: dict) -> None: + self.state.activate_member(body["book"], src) + + async def _on_decline(self, src: str, body: dict) -> None: + self.state.unshare(body["book"], src) + + async def _on_leave(self, src: str, body: dict) -> None: + self.state.unshare(body["book"], src) + + async def _on_pull(self, src: str, body: dict) -> None: + book_id = body["book"] + member = self.state.member_state(book_id, src) + if member is None: + # It still thinks it follows a book we let go of (the revoke was + # lost): say so, once per question, and it will stop asking. + meta = self.state.book(book_id) + if meta is not None and meta["role"] == cs.OWNER: + await self._send(src, _REVOKE, {"book": book_id}) + return + if member["state"] != "active": + self.state.activate_member(book_id, src) + since = body.get("since") + since = since if isinstance(since, int) and not isinstance(since, bool) and since >= 0 else 0 + entries, top, more = self.state.changes_since(book_id, since, PAGE_ENTRIES) + await self._push_to(src, book_id, entries, page=True, top=top, more=more) + + async def _on_push(self, src: str, body: dict) -> None: + book_id = body["book"] + entries = body.get("entries") + if not isinstance(entries, list) or len(entries) > PAGE_ENTRIES * 2: + return + meta = self.state.book(book_id) + if meta is None: + return + if meta["role"] == cs.OWNER: + member = meta["members"].get(src) + if member is None or member["state"] != "active" or member["role"] != "write": + return + taken = self.state.merge(book_id, entries, from_owner=False, writer=src) + ids = [e.get("id") for e in entries if isinstance(e, dict) and cs.is_cid(e.get("id"))] + await self._send(src, _ACK, {"book": book_id, "ids": ids}) + if taken: + await self.after_change(book_id, taken, skip=src) + return + if meta["owner"] != src: + return + self.state.merge(book_id, entries, from_owner=True) + if body.get("page") is True: + top = body.get("top") + top = top if isinstance(top, int) and not isinstance(top, bool) else 0 + seqs = [e.get("seq") for e in entries if isinstance(e, dict) + and isinstance(e.get("seq"), int) and not isinstance(e.get("seq"), bool)] + if body.get("more") is True and seqs: + self.state.mark_synced(book_id, max(seqs)) + await self._pull(book_id) + else: + self.state.mark_synced(book_id, max([top] + seqs)) + + async def _on_ack(self, src: str, body: dict) -> None: + meta = self.state.book(body["book"]) + if meta is None or meta["role"] == cs.OWNER or meta["owner"] != src: + return + ids = body.get("ids") + if isinstance(ids, list): + self.state.clear_dirty(body["book"], [i for i in ids if cs.is_cid(i)][:cs.MAX_ENTRIES]) + + async def _on_revoke(self, src: str, body: dict) -> None: + meta = self.state.book(body["book"]) + if meta is None or meta["role"] == cs.OWNER or meta["owner"] != src: + return + if self.state.detach(body["book"]): + self._spawn(self.notify( + "contacts-revoked", "A shared contact book is now only on this node")) + + +_HANDLERS = { + _OFFER: ContactsApp._on_offer, + _ACCEPT: ContactsApp._on_accept, + _DECLINE: ContactsApp._on_decline, + _LEAVE: ContactsApp._on_leave, + _PULL: ContactsApp._on_pull, + _PUSH: ContactsApp._on_push, + _ACK: ContactsApp._on_ack, + _REVOKE: ContactsApp._on_revoke, +} diff --git a/src/apps/contacts_link.py b/src/apps/contacts_link.py new file mode 100644 index 00000000..d364773d --- /dev/null +++ b/src/apps/contacts_link.py @@ -0,0 +1,77 @@ +""" +How one app on this node reaches the address books. + +Chat does not keep a list of contacts of its own: it asks the contacts app, the +way any app would, through the operations that app declares (``API`` in +:mod:`.contacts_web`). What makes it *standard* is the second half — each +operation names a permission (``contacts.read``, ``contacts.write``), and the +link asks the permission book whether the app a link speaks for was given it +before the call is made. An app that was refused gets a refusal, not a +degraded answer, and says so to the person using it, who can grant it from the +Apps page or from the prompt the app shows. + +The link holds nothing: the host and the book are looked up on every call, so a +contacts app that was stopped a second ago is not reachable through a reference +this object kept. +""" +from __future__ import annotations + +from .. import app_api + +APP = "contacts" + + +class Unavailable(Exception): + """The contacts app is not running here.""" + + +class Denied(Exception): + """The app this link speaks for was not given the permission.""" + + def __init__(self, permission: str) -> None: + super().__init__(permission) + self.permission = permission + + +class ContactsLink: + def __init__(self, host, perms, app_hex: str) -> None: + self._host = host + self._perms = perms + self._app = app_hex + + def _live_host(self): + host = self._host() if callable(self._host) else self._host + return host + + def access(self) -> str: + """What the app behind this link may do with the books right now: + ``"write"``, ``"read"``, ``"denied"`` (running, but not allowed) or + ``"unavailable"`` (no contacts app on this node).""" + host = self._live_host() + if host is None or host.bridge(APP) is None: + return "unavailable" + if self._perms is None: + return "denied" + if self._perms.allows(self._app, "contacts.write"): + return "write" + if self._perms.allows(self._app, "contacts.read"): + return "read" + return "denied" + + def call(self, op: str, **args) -> dict: + """One operation of the contacts app, as this app. Raises + :class:`Unavailable`, :class:`Denied` or + :class:`~src.app_api.AppAPIError`.""" + host = self._live_host() + if host is None or host.bridge(APP) is None: + raise Unavailable(APP) + surface = app_api.AppAPI(host) + declared = surface.find(APP, op) + if declared is None: + raise Unavailable(op) + need = declared.get("permission") or "" + if self._perms is None or not need or not self._perms.allows(self._app, need): + raise Denied(need or "contacts") + return surface.call(APP, op, {k: v for k, v in args.items() if v is not None}) + + diff --git a/src/apps/contacts_state.py b/src/apps/contacts_state.py new file mode 100644 index 00000000..830266ae --- /dev/null +++ b/src/apps/contacts_state.py @@ -0,0 +1,835 @@ +""" +Contacts state — address books, and the contacts in them. + +A **book** is a named list of **contacts**; a contact is a name a person chose +and the nodes it answers to. A node keeps several books (family, work, the ones +other people shared), and every other app on the node asks for them through the +permission model (:mod:`src.app_perms`, ``contacts.read`` / ``contacts.write``) +rather than keeping a list of its own. + +**A name here is somebody's own word.** What a node is called by *itself* is a +signed claim the node holds (:mod:`src.pseudo_dir`); what a contact is called is +what the person who owns the book wrote. The two are kept apart on purpose: the +second may be a nickname, and when it came from a shared book it is a stranger's +opinion that the owner of this node accepted once. + +**A book can be shared.** The node that made it owns it; a book offered to +another node is a pending invitation until a human accepts, and then a +*mirror* — read-only, or writable when the owner said so — that the two keep in +step by the sync messages in :mod:`.contacts`. This module is only the data and +its rules: no sockets, no clock beyond a timestamp, no node. + +Every collection is hard-bounded. A book is one value of the node's per-app +drawer (a quarter of a megabyte at most), so a book that no longer fits is +refused rather than truncated; tombstones are kept to carry a deletion to the +nodes that have not heard of it yet, and are the first thing to go when room is +needed. +""" +from __future__ import annotations + +import json +import re +import secrets +import threading +import time + +from .. import pseudo as pseudo_mod + +BOOK_PREFIX = "book:" +_INDEX_KEY = "index" +_VALUE_MAX = 240 * 1024 # a drawer value is 256 KiB; keep clear of the edge + +MAX_BOOKS = 32 +MAX_ENTRIES = 1000 # per book, tombstones included +MAX_NODES = 8 # nodes one contact answers to +MAX_NAME = 64 +MAX_LABEL = 32 +MAX_NOTE = 512 +MAX_BOOK_NAME = 48 +MAX_MEMBERS = 32 # nodes one book is shared with +MAX_OFFERS = 16 # invitations waiting for a human, in all +MAX_OFFERS_PER_SENDER = 2 +OFFER_TTL = 14 * 24 * 3600.0 +TOMBSTONE_TTL = 90 * 24 * 3600.0 +MAX_RESULTS = 200 +MAX_COUNTER = 1 << 40 # an entry's version cannot be pushed past this + +COLORS = ("blue", "green", "teal", "amber", "red", "purple", "pink", "gray") +ROLES = ("read", "write") +OWNER, LOCAL = "owner", "owner" # my role in a book that is mine +MEMBER_STATES = ("offered", "active") + +_ID_RE = re.compile(r"^[0-9a-f]{40}$") +_BOOK_RE = re.compile(r"^[0-9a-f]{32}$") +_CID_RE = re.compile(r"^[0-9a-f]{32}$") + + +def is_node(value) -> bool: + return isinstance(value, str) and _ID_RE.match(value) is not None + + +def is_book_id(value) -> bool: + return isinstance(value, str) and _BOOK_RE.match(value) is not None + + +def is_cid(value) -> bool: + return isinstance(value, str) and _CID_RE.match(value) is not None + + +def _count(value) -> int: + """A counter read back from storage or the wire: a whole number in range.""" + if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= MAX_COUNTER: + return value + return 0 + + +def _line(value, limit: int) -> str: + """One printable line, bounded — what a person typed or a peer sent.""" + if not isinstance(value, str): + return "" + clean = "".join(ch for ch in value if ch.isprintable()) + return " ".join(clean.split())[:limit] + + +def _text(value, limit: int) -> str: + """A note: printable, newlines kept, bounded.""" + if not isinstance(value, str): + return "" + clean = "".join(ch for ch in value if ch.isprintable() or ch in "\n") + return clean.strip()[:limit] + + +def clean_entry(raw, *, keep_version: bool = False): + """A contact brought inside what is kept, or ``None`` when it is not one. + + Used on what a person typed *and* on what a peer sent: both are untrusted + until they have been through here. ``keep_version`` is for the second — + a sync message carries the version the entry was written under.""" + if not isinstance(raw, dict): + return None + cid = raw.get("id") + if not is_cid(cid): + return None + nodes, seen = [], set() + listed = raw.get("nodes") + for item in (listed if isinstance(listed, list) else [])[:MAX_NODES]: + if isinstance(item, str): + item = {"id": item} + if not isinstance(item, dict) or not is_node(item.get("id")): + continue + if item["id"] in seen: + continue + seen.add(item["id"]) + nodes.append({"id": item["id"], "label": _line(item.get("label"), MAX_LABEL)}) + entry = {"id": cid, "name": _line(raw.get("name"), MAX_NAME), "nodes": nodes, + "note": _text(raw.get("note"), MAX_NOTE), "fav": raw.get("fav") is True, + "del": raw.get("del") is True, "t": 0.0, "seq": 0, "v": [0, ""]} + if not entry["del"] and not entry["name"] and not nodes: + return None + t = raw.get("t") + entry["t"] = float(t) if isinstance(t, (int, float)) and 0 <= t < 1e11 else 0.0 + if keep_version: + version = raw.get("v") + if (not isinstance(version, list) or len(version) != 2 + or not isinstance(version[0], int) or isinstance(version[0], bool) + or not 0 <= version[0] <= MAX_COUNTER + or not (version[1] == "" or is_node(version[1]))): + return None + entry["v"] = [version[0], version[1]] + entry["seq"] = _count(raw.get("seq")) + if entry["del"]: + # A tombstone says only that this contact is gone. What it used to hold + # is not kept: the deletion is the whole message. + entry.update(name="", nodes=[], note="", fav=False) + return entry + + +def newer(candidate: dict, current: dict | None) -> bool: + """Does ``candidate`` win over ``current``? Higher counter, then higher + writer id — a total order, so every replica settles on the same entry.""" + if current is None: + return True + return tuple(candidate["v"]) > tuple(current["v"]) + + +def search_key(entry: dict) -> str: + parts = [entry.get("name", "")] + [n.get("label", "") for n in entry.get("nodes", ())] + return pseudo_mod.fold(" ".join(p for p in parts if p)) + + +class ContactsState: + """The books of one node, thread-safe and persisted in an encrypted drawer. + + ``store`` is a :class:`~src.apps.chat_state.DrawerStore`; without one the + state lives in memory (tests, demos). ``me`` is this node's id in hex — the + writer stamped on what the person here changes, which is what makes two + nodes' simultaneous edits of one contact settle the same way everywhere.""" + + def __init__(self, store=None, me: str = "") -> None: + self._store = store + self.me = me if is_node(me) else "" + self._lock = threading.RLock() + self._books: dict[str, dict] = {} # id -> meta (no entries) + self._entries: dict[str, dict] = {} # book id -> {cid -> entry} + self._folded: dict[tuple, str] = {} # (book, cid) -> search key + self._offers: list[dict] = [] + self.default = "" + self.version = 0 # bumped by every change, read by pollers + self._load() + + # -- persistence ------------------------------------------------------- + + def _read(self, key: str): + if self._store is None: + return None + try: + blob = self._store.get(key) + return json.loads(blob.decode("utf-8")) if blob else None + except Exception: + return None # unreadable or corrupt: start empty, never crash + + def _write(self, key: str, doc) -> bool: + if self._store is None: + return True + blob = json.dumps(doc, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(blob) > _VALUE_MAX: + return False + try: + return bool(self._store.put(key, blob)) + except Exception: + return False + + def _load(self) -> None: + index = self._read(_INDEX_KEY) + if not isinstance(index, dict): + return + books = index.get("books") + for book_id in (list(books) if isinstance(books, list) else [])[:MAX_BOOKS]: + if not is_book_id(book_id): + continue + doc = self._read(BOOK_PREFIX + book_id) + meta = self._clean_meta(doc.get("meta") if isinstance(doc, dict) else None, book_id) + if meta is None: + continue + self._books[book_id] = meta + entries = {} + raw_entries = doc.get("entries") + for raw in (list(raw_entries.values()) if isinstance(raw_entries, dict) else [])[:MAX_ENTRIES]: + entry = clean_entry(raw, keep_version=True) + if entry is not None: + entries[entry["id"]] = entry + self._entries[book_id] = entries + for cid, entry in entries.items(): + self._folded[(book_id, cid)] = search_key(entry) + default = index.get("default") + self.default = default if default in self._books else "" + offers = index.get("offers") + for raw in (offers if isinstance(offers, list) else [])[:MAX_OFFERS]: + offer = self._clean_offer(raw) + if offer is not None: + self._offers.append(offer) + + def _clean_meta(self, raw, book_id: str): + if not isinstance(raw, dict): + return None + owner = raw.get("owner") + role = raw.get("role") + if role not in (OWNER,) + ROLES: + return None + if role != OWNER and not is_node(owner): + return None + members = {} + listed = raw.get("members") + for node, row in (list(listed.items()) if isinstance(listed, dict) else [])[:MAX_MEMBERS]: + if is_node(node) and isinstance(row, dict) and row.get("role") in ROLES: + state = row.get("state") if row.get("state") in MEMBER_STATES else "offered" + members[node] = {"role": row["role"], "state": state, + "tries": min(_count(row.get("tries")), 99)} + dirty = [c for c in (raw.get("dirty") if isinstance(raw.get("dirty"), list) else []) + if is_cid(c)][:MAX_ENTRIES] + return {"id": book_id, + "name": _line(raw.get("name"), MAX_BOOK_NAME) or "Contacts", + "color": raw.get("color") if raw.get("color") in COLORS else "blue", + "owner": owner if role != OWNER else self.me, + "role": role, "members": members, + "seq": _count(raw.get("seq")), "synced": _count(raw.get("synced")), + "created": float(raw.get("created") or 0) if isinstance(raw.get("created"), (int, float)) else 0.0, + "dirty": dirty} + + def _clean_offer(self, raw): + if not isinstance(raw, dict): + return None + sender, book = raw.get("from"), raw.get("book") + if not is_node(sender) or not is_book_id(book) or raw.get("role") not in ROLES: + return None + at = raw.get("at") + return {"from": sender, "book": book, + "name": _line(raw.get("name"), MAX_BOOK_NAME) or "Contacts", + "color": raw.get("color") if raw.get("color") in COLORS else "blue", + "role": raw["role"], "count": min(_count(raw.get("count")), MAX_ENTRIES), + "at": float(at) if isinstance(at, (int, float)) else 0.0} + + def _persist_index(self) -> None: + self._write(_INDEX_KEY, {"books": list(self._books), "default": self.default, + "offers": self._offers}) + + def _persist_book(self, book_id: str) -> bool: + doc = {"meta": self._books[book_id], "entries": self._entries[book_id]} + return self._write(BOOK_PREFIX + book_id, doc) + + def _changed(self) -> None: + self.version += 1 + + # -- books ------------------------------------------------------------- + + def ensure_default(self) -> str: + """The book new contacts go to, made on first use.""" + with self._lock: + if self.default in self._books: + return self.default + for book_id, meta in self._books.items(): + if meta["role"] == OWNER: + self.default = book_id + self._persist_index() + return book_id + book_id = self.create_book("Contacts", "blue") + return book_id or "" + + def create_book(self, name: str, color: str = "blue") -> str: + with self._lock: + if len(self._books) >= MAX_BOOKS: + return "" + book_id = secrets.token_hex(16) + meta = {"id": book_id, "name": _line(name, MAX_BOOK_NAME) or "Contacts", + "color": color if color in COLORS else "blue", "owner": self.me, + "role": OWNER, "members": {}, "seq": 0, "synced": 0, + "created": time.time(), "dirty": []} + self._books[book_id] = meta + self._entries[book_id] = {} + if not self.default: + self.default = book_id + self._persist_book(book_id) + self._persist_index() + self._changed() + return book_id + + def set_book(self, book_id: str, *, name=None, color=None) -> bool: + with self._lock: + meta = self._books.get(book_id) + if meta is None or meta["role"] != OWNER: + return False + if isinstance(name, str) and _line(name, MAX_BOOK_NAME): + meta["name"] = _line(name, MAX_BOOK_NAME) + if color in COLORS: + meta["color"] = color + self._persist_book(book_id) + self._changed() + return True + + def set_default(self, book_id: str) -> bool: + with self._lock: + meta = self._books.get(book_id) + if meta is None or meta["role"] not in (OWNER, "write"): + return False + self.default = book_id + self._persist_index() + self._changed() + return True + + def delete_book(self, book_id: str) -> dict | None: + """Remove a book from this node. Returns what it was (for the app to + tell the other side), or ``None`` when there was no such book.""" + with self._lock: + meta = self._books.pop(book_id, None) + if meta is None: + return None + self._entries.pop(book_id, None) + for key in [k for k in self._folded if k[0] == book_id]: + del self._folded[key] + if self.default == book_id: + self.default = "" + for other, row in self._books.items(): + if row["role"] == OWNER: + self.default = other + break + if self._store is not None: + try: + self._store.delete(BOOK_PREFIX + book_id) + except Exception: + pass + self._persist_index() + self._changed() + return meta + + def book(self, book_id: str) -> dict | None: + with self._lock: + meta = self._books.get(book_id) + return json.loads(json.dumps(meta)) if meta else None + + def books(self) -> list[dict]: + """Every book, without its contacts, with how many it holds.""" + with self._lock: + out = [] + for book_id, meta in self._books.items(): + live = sum(1 for e in self._entries[book_id].values() if not e["del"]) + row = json.loads(json.dumps(meta)) + row.update(count=live, default=(book_id == self.default), + writable=meta["role"] in (OWNER, "write"), + shared=bool(meta["members"]) or meta["role"] != OWNER) + row.pop("dirty", None) + out.append(row) + out.sort(key=lambda r: (not r["default"], r["name"].casefold())) + return out + + def can_write(self, book_id: str) -> bool: + with self._lock: + meta = self._books.get(book_id) + return bool(meta and meta["role"] in (OWNER, "write")) + + # -- contacts ---------------------------------------------------------- + + def _store_entry(self, book_id: str, entry: dict) -> bool: + """Put ``entry`` in its book if it fits; the book is left as it was + when it does not.""" + entries = self._entries[book_id] + previous = entries.get(entry["id"]) + if previous is None and len(entries) >= MAX_ENTRIES: + self._prune(book_id) + if len(entries) >= MAX_ENTRIES: + return False + entries[entry["id"]] = entry + if not self._persist_book(book_id): + if previous is None: + del entries[entry["id"]] + else: + entries[entry["id"]] = previous + return False + self._folded[(book_id, entry["id"])] = search_key(entry) + self._changed() + return True + + def _prune(self, book_id: str) -> None: + """Make room: forget the tombstones that have had time to travel.""" + entries = self._entries[book_id] + horizon = time.time() - TOMBSTONE_TTL + for cid in [c for c, e in entries.items() if e["del"] and e["t"] < horizon]: + del entries[cid] + self._folded.pop((book_id, cid), None) + + def _stamp(self, book_id: str, entry: dict, previous: dict | None) -> dict: + meta = self._books[book_id] + counter = (previous["v"][0] if previous else 0) + 1 + entry["v"] = [min(counter, MAX_COUNTER), self.me] + entry["t"] = time.time() + if meta["role"] == OWNER: + meta["seq"] += 1 + entry["seq"] = meta["seq"] + else: + entry["seq"] = previous["seq"] if previous else 0 + if entry["id"] not in meta["dirty"]: + meta["dirty"].append(entry["id"]) + return entry + + def save(self, book_id: str, fields: dict, cid: str | None = None): + """Create or change a contact. ``(True, contact)`` or ``(False, why)``. + + ``why`` is one of ``no_book``, ``read_only``, ``empty``, ``full``.""" + with self._lock: + meta = self._books.get(book_id) + if meta is None: + return False, "no_book" + if meta["role"] not in (OWNER, "write"): + return False, "read_only" + previous = self._entries[book_id].get(cid) if cid else None + if cid and (previous is None or previous["del"]): + return False, "no_contact" + raw = dict(previous or {}) + raw.update({k: fields[k] for k in ("name", "nodes", "note", "fav") if k in fields}) + raw["id"] = cid or secrets.token_hex(16) + raw["del"] = False + entry = clean_entry(raw) + if entry is None: + return False, "empty" + saved = (meta["seq"], list(meta["dirty"])) + entry = self._stamp(book_id, entry, previous) + if not self._store_entry(book_id, entry): + meta["seq"], meta["dirty"] = saved + return False, "full" + return True, json.loads(json.dumps(entry)) + + def remove(self, book_id: str, cid: str): + with self._lock: + meta = self._books.get(book_id) + if meta is None: + return False, "no_book" + if meta["role"] not in (OWNER, "write"): + return False, "read_only" + previous = self._entries[book_id].get(cid) + if previous is None or previous["del"]: + return False, "no_contact" + saved = (meta["seq"], list(meta["dirty"])) + tomb = clean_entry({"id": cid, "del": True}) + tomb = self._stamp(book_id, tomb, previous) + if not self._store_entry(book_id, tomb): + meta["seq"], meta["dirty"] = saved + return False, "full" + return True, None + + def add_node(self, node: str, name: str = "", book_id: str = ""): + """The one thing other apps ask: "this node is somebody I know". + + Idempotent per book: a node already in the book is not added twice.""" + if not is_node(node): + return False, "bad_node" + with self._lock: + target = book_id or self.ensure_default() + if not target or target not in self._books: + return False, "no_book" + for entry in self._entries[target].values(): + if not entry["del"] and any(n["id"] == node for n in entry["nodes"]): + if name and not entry["name"] and self.can_write(target): + return self.save(target, {"name": name}, entry["id"]) + return True, json.loads(json.dumps(entry)) + return self.save(target, {"name": name or "", "nodes": [{"id": node}]}) + + def remove_node(self, node: str, book_id: str = "") -> int: + """Take a node out of the books it is in (one book, or every writable + one). A contact left with no node and no name goes with it.""" + done = 0 + with self._lock: + for target in ([book_id] if book_id else list(self._books)): + if not self.can_write(target): + continue + for entry in list(self._entries.get(target, {}).values()): + if entry["del"] or not any(n["id"] == node for n in entry["nodes"]): + continue + rest = [n for n in entry["nodes"] if n["id"] != node] + if rest or entry["name"]: + ok, _ = self.save(target, {"nodes": rest}, entry["id"]) + else: + ok, _ = self.remove(target, entry["id"]) + done += 1 if ok else 0 + return done + + def get(self, book_id: str, cid: str): + with self._lock: + entry = self._entries.get(book_id, {}).get(cid) + return json.loads(json.dumps(entry)) if entry and not entry["del"] else None + + def entries(self, book_id: str | None = None, *, limit: int = MAX_ENTRIES * MAX_BOOKS) -> list[dict]: + """Live contacts (never tombstones), a book's or every book's, by name.""" + with self._lock: + out = [] + for target in ([book_id] if book_id else list(self._books)): + for entry in self._entries.get(target, {}).values(): + if not entry["del"]: + row = json.loads(json.dumps(entry)) + row["book"] = target + out.append(row) + out.sort(key=lambda r: (not r["fav"], (r["name"] or r["nodes"][0]["id"]).casefold())) + return out[:limit] + + def raw_entries(self, book_id: str, cids) -> list[dict]: + """The named entries as stored, tombstones included — what goes on the + wire when something changed.""" + with self._lock: + entries = self._entries.get(book_id, {}) + return [json.loads(json.dumps(entries[c])) for c in cids if c in entries] + + def find(self, node: str) -> list[dict]: + """Every contact that answers to ``node``, my own books first.""" + with self._lock: + out = [] + for book_id, entries in self._entries.items(): + for entry in entries.values(): + if not entry["del"] and any(n["id"] == node for n in entry["nodes"]): + row = json.loads(json.dumps(entry)) + row["book"] = book_id + row["book_name"] = self._books[book_id]["name"] + row["mine"] = self._books[book_id]["role"] == OWNER + out.append(row) + out.sort(key=lambda r: (not r["mine"], r["book_name"].casefold())) + return out + + def node_names(self) -> dict: + """node id -> the name my own books give it, for every node known.""" + with self._lock: + out: dict[str, str] = {} + for book_id, entries in self._entries.items(): + mine = self._books[book_id]["role"] == OWNER + for entry in entries.values(): + if entry["del"] or not entry["name"]: + continue + for node in entry["nodes"]: + if mine or node["id"] not in out: + out[node["id"]] = entry["name"] + return out + + def node_ids(self) -> set: + """Every node a screen of this app can name: the ones in a contact, and + the owner and members of each book.""" + with self._lock: + out = {n["id"] for entries in self._entries.values() + for e in entries.values() if not e["del"] for n in e["nodes"]} + for meta in self._books.values(): + out.update(meta["members"]) + if meta.get("owner") and meta["owner"] != self.me: + out.add(meta["owner"]) + return out + + def search(self, query: str, limit: int = 50) -> list[dict]: + """Contacts matching what was typed: a name, a label, a note word or the + start of a node id. Best match first; the same ranking the node's + directory uses, so a name found here and a name found on the network + sort alike.""" + needle = pseudo_mod.fold(query) + raw = query.strip().lower() if isinstance(query, str) else "" + limit = max(1, min(int(limit), MAX_RESULTS)) + if not needle: + return [] + by_id = len(raw) >= 4 and re.fullmatch(r"[0-9a-f]+", raw) is not None + scored = [] + with self._lock: + for (book_id, cid), folded in self._folded.items(): + entry = self._entries[book_id].get(cid) + if entry is None or entry["del"]: + continue + rank = pseudo_mod.rank_folded(needle, folded) + if rank is None and by_id and any(n["id"].startswith(raw) for n in entry["nodes"]): + rank = pseudo_mod.PREFIX + if rank is None and len(needle) >= 3 and needle in pseudo_mod.fold(entry["note"]): + rank = pseudo_mod.CONTAINS + 1 + if rank is None: + continue + row = json.loads(json.dumps(entry)) + row["book"] = book_id + row["book_name"] = self._books[book_id]["name"] + scored.append((rank, not entry["fav"], len(entry["name"]), row)) + scored.sort(key=lambda s: s[:3]) + return [row for *_, row in scored[:limit]] + + # -- sharing: the owner's side ----------------------------------------- + + def share(self, book_id: str, node: str, role: str) -> bool: + with self._lock: + meta = self._books.get(book_id) + if (meta is None or meta["role"] != OWNER or role not in ROLES + or not is_node(node) or node == self.me): + return False + if node not in meta["members"] and len(meta["members"]) >= MAX_MEMBERS: + return False + previous = meta["members"].get(node) + meta["members"][node] = {"role": role, + "state": previous["state"] if previous else "offered", + "tries": 0 if not previous else previous.get("tries", 0)} + self._persist_book(book_id) + self._changed() + return True + + def unshare(self, book_id: str, node: str) -> bool: + with self._lock: + meta = self._books.get(book_id) + if meta is None or meta["role"] != OWNER or node not in meta["members"]: + return False + del meta["members"][node] + self._persist_book(book_id) + self._changed() + return True + + def member_state(self, book_id: str, node: str): + with self._lock: + meta = self._books.get(book_id) + return dict(meta["members"][node]) if meta and node in meta["members"] else None + + def activate_member(self, book_id: str, node: str) -> bool: + with self._lock: + meta = self._books.get(book_id) + if meta is None or meta["role"] != OWNER or node not in meta["members"]: + return False + meta["members"][node]["state"] = "active" + self._persist_book(book_id) + self._changed() + return True + + def note_try(self, book_id: str, node: str) -> int: + with self._lock: + meta = self._books.get(book_id) + row = meta["members"].get(node) if meta else None + if row is None: + return 99 + row["tries"] = min(99, row.get("tries", 0) + 1) + self._persist_book(book_id) + return row["tries"] + + def changes_since(self, book_id: str, since: int, limit: int) -> tuple[list, int, bool]: + """What the owner has that a member at ``since`` has not: up to + ``limit`` entries by sequence, the owner's current sequence, and + whether more remain.""" + with self._lock: + meta = self._books.get(book_id) + if meta is None: + return [], 0, False + newer_entries = sorted((e for e in self._entries[book_id].values() if e["seq"] > since), + key=lambda e: e["seq"]) + page = newer_entries[:limit] + return ([json.loads(json.dumps(e)) for e in page], meta["seq"], + len(newer_entries) > len(page)) + + # -- sharing: the member's side ---------------------------------------- + + def offers(self) -> list[dict]: + with self._lock: + self._expire_offers() + return [dict(o) for o in self._offers] + + def _expire_offers(self) -> None: + horizon = time.time() - OFFER_TTL + kept = [o for o in self._offers if o["at"] >= horizon] + if len(kept) != len(self._offers): + self._offers = kept + self._persist_index() + + def receive_offer(self, sender: str, book: str, name: str, color: str, + role: str, count: int) -> bool: + """Keep an invitation until a human answers it. Bounded in all and per + sender, and a repeat replaces itself rather than queueing.""" + offer = self._clean_offer({"from": sender, "book": book, "name": name, + "color": color, "role": role, "count": count, + "at": time.time()}) + if offer is None: + return False + with self._lock: + self._expire_offers() + if book in self._books: + return False + same = [o for o in self._offers if o["from"] == sender and o["book"] == book] + if same: + self._offers.remove(same[0]) + elif (len(self._offers) >= MAX_OFFERS + or sum(1 for o in self._offers if o["from"] == sender) >= MAX_OFFERS_PER_SENDER): + return False + self._offers.append(offer) + self._persist_index() + self._changed() + return True + + def take_offer(self, book: str, sender: str) -> dict | None: + with self._lock: + for offer in self._offers: + if offer["book"] == book and offer["from"] == sender: + self._offers.remove(offer) + self._persist_index() + self._changed() + return offer + return None + + def accept_offer(self, offer: dict) -> bool: + """Make the mirror an accepted invitation promised.""" + with self._lock: + if len(self._books) >= MAX_BOOKS or offer["book"] in self._books: + return False + meta = {"id": offer["book"], "name": offer["name"], "color": offer["color"], + "owner": offer["from"], "role": offer["role"], "members": {}, + "seq": 0, "synced": 0, "created": time.time(), "dirty": []} + self._books[offer["book"]] = meta + self._entries[offer["book"]] = {} + self._persist_book(offer["book"]) + self._persist_index() + self._changed() + return True + + def detach(self, book_id: str) -> bool: + """The owner let this node go: the copy stays, and becomes this node's.""" + with self._lock: + meta = self._books.get(book_id) + if meta is None or meta["role"] == OWNER: + return False + meta.update(role=OWNER, owner=self.me, members={}, dirty=[], + seq=max((e["seq"] for e in self._entries[book_id].values()), default=0)) + self._persist_book(book_id) + self._changed() + return True + + def set_role(self, book_id: str, role: str) -> bool: + with self._lock: + meta = self._books.get(book_id) + if meta is None or meta["role"] == OWNER or role not in ROLES: + return False + meta["role"] = role + self._persist_book(book_id) + self._changed() + return True + + def mirrors_of(self, owner: str) -> list[str]: + with self._lock: + return [b for b, m in self._books.items() if m["role"] != OWNER and m["owner"] == owner] + + def dirty_entries(self, book_id: str, limit: int) -> list[dict]: + with self._lock: + meta = self._books.get(book_id) + if meta is None: + return [] + rows = [self._entries[book_id][c] for c in meta["dirty"] if c in self._entries[book_id]] + return [json.loads(json.dumps(e)) for e in rows[:limit]] + + def clear_dirty(self, book_id: str, cids) -> None: + with self._lock: + meta = self._books.get(book_id) + if meta is None: + return + drop = set(cids) + meta["dirty"] = [c for c in meta["dirty"] if c not in drop] + self._persist_book(book_id) + + def mark_synced(self, book_id: str, seq: int) -> None: + with self._lock: + meta = self._books.get(book_id) + if meta is not None and 0 <= seq <= MAX_COUNTER and seq > meta["synced"]: + meta["synced"] = seq + self._persist_book(book_id) + + # -- merging what a peer sent ------------------------------------------ + + def merge(self, book_id: str, entries, *, from_owner: bool, writer: str = "") -> list[str]: + """Fold peer entries into a book. Returns the ids that were taken. + + ``from_owner`` is the direction: an owner's entries arrive with the + sequence the owner gave them; a member's are stamped here, with the + next sequence, because the owner is the only one who numbers.""" + taken: list[str] = [] + with self._lock: + meta = self._books.get(book_id) + if meta is None: + return taken + for raw in entries[:MAX_ENTRIES] if isinstance(entries, list) else []: + entry = clean_entry(raw, keep_version=True) + if entry is None: + continue + if not is_node(entry["v"][1]): + continue + if not from_owner and entry["v"][1] != writer: + # A member writes under its own id and nobody else's. + continue + current = self._entries[book_id].get(entry["id"]) + if not newer(entry, current): + continue + if meta["role"] == OWNER: + meta["seq"] += 1 + entry["seq"] = meta["seq"] + if self._store_entry(book_id, entry): + taken.append(entry["id"]) + if meta["role"] != OWNER and entry["id"] in meta["dirty"] and from_owner: + # The owner's word on this contact came back to us; ours + # either won (and the owner has it) or lost (and is gone). + meta["dirty"].remove(entry["id"]) + elif meta["role"] == OWNER: + meta["seq"] -= 1 + if taken: + self._persist_book(book_id) + return taken + + def stats(self) -> dict: + with self._lock: + return {"books": len(self._books), "offers": len(self._offers), + "contacts": sum(1 for es in self._entries.values() + for e in es.values() if not e["del"])} diff --git a/src/apps/contacts_web.py b/src/apps/contacts_web.py new file mode 100644 index 00000000..43ba7d95 --- /dev/null +++ b/src/apps/contacts_web.py @@ -0,0 +1,444 @@ +""" +The contacts bridge — what the console's page and the other apps ask of +:class:`~src.apps.contacts.ContactsApp`. + +Two doors onto one set of books: + +* **The page** (`/contacts`) is driven by a person, signed in to the console. + Its calls arrive as :meth:`handle` with an operation name and a JSON object. +* **Other apps** reach the books through the app API (``API`` below), and every + operation there names the permission it needs — ``contacts.read`` or + ``contacts.write`` — which the plane checks against what a human granted that + app. Chat is the first of them, and asks like any other app would. + +The page is the owner at the keyboard, so it holds no permission; the API is for +somebody else, so it holds only what it was given. +""" +from __future__ import annotations + +import asyncio +import re + +from .. import app_api +from .. import console_feed +from . import contacts_state as cs + +_CALL_TIMEOUT = 20.0 +_SEARCH_TIMEOUT = 25.0 +_NAME_TIMEOUT = 3.0 +MAX_NAMES = 3000 +MAX_IMPORT = 600_000 # bytes of vCard text +MAX_IMPORT_CARDS = 1000 +_CHANGES = {"no_book": "That book does not exist.", + "read_only": "This book is shared with you to read only.", + "no_contact": "That contact does not exist.", + "empty": "A contact needs a name or a node.", + "full": "This book is full.", + "bad_node": "That is not a node id.", + "not_owner": "Only the node that made a book can share it.", + "self": "That is this node.", + "gone": "That invitation is gone."} + + +def _row(entry: dict) -> dict: + """A contact as another app sees it: enough to show the person, never the + note written beside them.""" + return {"id": entry["id"], "book": entry.get("book", ""), + "name": entry["name"], "nodes": entry["nodes"], "fav": entry["fav"]} + + +class ContactsBridge: + """Thread-safe front of the contacts app: a person's page, and the API.""" + + API = ( + app_api.operation( + "books", "The address books on this node", + permission="contacts.read"), + app_api.operation( + "list", "The contacts in one book, or in all of them", + [app_api.param("book", "text", required=False, default="")], + permission="contacts.read"), + app_api.operation( + "names", "Every node in the books and the name it was given", + permission="contacts.read"), + app_api.operation( + "search", "Find saved contacts by name or node id", + [app_api.param("query", "text"), + app_api.param("limit", "count", required=False, default=20)], + permission="contacts.read"), + app_api.operation( + "find", "The contacts that answer to a node", + [app_api.param("node", "node")], + permission="contacts.read"), + app_api.operation( + "add", "Save a node as a contact", + [app_api.param("node", "node"), + app_api.param("name", "text", required=False, default=""), + app_api.param("book", "text", required=False, default="")], + changes=True, permission="contacts.write"), + app_api.operation( + "remove", "Take a node out of the contacts", + [app_api.param("node", "node"), + app_api.param("book", "text", required=False, default="")], + changes=True, permission="contacts.write"), + ) + + def __init__(self, contacts_app, *, node_id=None) -> None: + self._app = contacts_app + self.state = contacts_app.state + self._loop: asyncio.AbstractEventLoop | None = None + self._names_memo: tuple = (-1, {}) + + # -- lifecycle ---------------------------------------------------------- + + def start(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + def stop(self) -> None: + self._loop = None + + def _run(self, coro, timeout: float = _CALL_TIMEOUT): + if self._loop is None: + coro.close() + raise RuntimeError("bridge not started") + return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout=timeout) + + def _later(self, coro) -> None: + """Start something on the loop and do not wait for it: an edit is saved + before the other nodes hear of it, and the next sync round makes up for + a message that is lost.""" + loop = self._loop + if loop is None: + coro.close() + return + try: + loop.call_soon_threadsafe(self._app._spawn, coro) + except RuntimeError: + coro.close() + + # -- the app API -------------------------------------------------------- + + def api_books(self) -> dict: + return {"books": [{"id": b["id"], "name": b["name"], "color": b["color"], + "count": b["count"], "default": b["default"], + "writable": b["writable"], "shared": b["shared"]} + for b in self.state.books()]} + + def api_names(self) -> dict: + """Node id -> name, for every node in my books: what a chat screen + needs to say "Ada" instead of forty hex digits. Recomputed only when a + book changed, since a page asks for it on every refresh.""" + version = self.state.version + if self._names_memo[0] != version: + names = dict(list(self.state.node_names().items())[:MAX_NAMES]) + self._names_memo = (version, names) + return {"names": dict(self._names_memo[1]), "version": version} + + def api_list(self, book: str = "") -> dict: + if book and not cs.is_book_id(book): + raise app_api.AppAPIError("not a book") + rows = self.state.entries(book or None, limit=cs.MAX_RESULTS) + return {"contacts": [_row(r) for r in rows]} + + def api_search(self, query: str, limit: int = 20) -> dict: + rows = self.state.search(query, limit=limit) + return {"contacts": [_row(r) for r in rows]} + + def api_find(self, node: str) -> dict: + return {"contacts": [dict(_row(r), book_name=r["book_name"]) for r in self.state.find(node)]} + + def api_add(self, node: str, name: str = "", book: str = "") -> dict: + if book and not cs.is_book_id(book): + raise app_api.AppAPIError("not a book") + if not name: + try: + name = self._run(self._app.name_of(node), timeout=_NAME_TIMEOUT) + except Exception: + name = "" + ok, result = self.state.add_node(node, name, book) + if not ok: + raise app_api.AppAPIError(_CHANGES.get(result, "not added")) + target = result["id"] + for found in self.state.find(node): + if found["id"] == target: + self._later(self._app.after_change(found["book"], [target])) + return {"added": True, "contact": target, "book": found["book"]} + return {"added": True, "contact": target, "book": book} + + def api_remove(self, node: str, book: str = "") -> dict: + if book and not cs.is_book_id(book): + raise app_api.AppAPIError("not a book") + touched = {f["book"]: f["id"] for f in self.state.find(node) + if not book or f["book"] == book} + removed = self.state.remove_node(node, book) + for book_id, cid in touched.items(): + self._later(self._app.after_change(book_id, [cid])) + return {"removed": removed} + + # -- what the page reads ------------------------------------------------ + + def snapshot(self, book: str = "", *, have=None, proto: int = 1) -> dict: + """The books, the invitations waiting for an answer, and one book's + contacts, in the shape the page asked for.""" + books = self.state.books() + chosen = book if any(b["id"] == book for b in books) else ( + next((b["id"] for b in books if b["default"]), books[0]["id"] if books else "")) + contacts = self.state.entries(chosen) if chosen else [] + names = self._names(contacts, next((b for b in books if b["id"] == chosen), None)) + sections = { + "me": self._app.node_id.raw.hex() if self._app.node_id else "", + "books": books, + "offers": self.state.offers(), + "contacts": contacts, + "names": names, + } + return console_feed.build(sections, have=have, proto=proto, extra={ + "book": chosen, "version": self.state.version}) + + def _names(self, contacts, book=None) -> dict: + """What the node calls each node on this screen — its own word, shown + beside the one the person chose, never in place of it. The nodes a book + is shared with, and the one that owns it, are on screen too.""" + ids = [node["id"] for entry in contacts for node in entry["nodes"]] + if book: + ids += list(book.get("members") or {}) + ids.append(book.get("owner") or "") + out = {} + for node in ids: + name = self._app.names.get(node) + if name: + out[node] = name + return out + + def people(self, query: str, wide: bool = True) -> dict: + """Everyone who answers to ``query``: saved contacts first, then the + nodes the directory knows by that name — on a mesh of thousands, the + way to find somebody who is not in any book yet.""" + query = (query or "").strip()[:cs.MAX_NAME] + if not query: + return {"results": []} + saved = self.state.search(query, limit=30) + known = set() + results = [] + for row in saved: + for node in row["nodes"] or [{"id": "", "label": ""}]: + known.add(node["id"]) + results.append({"kind": "contact", "contact": row["id"], "book": row["book"], + "book_name": row["book_name"], "name": row["name"], + "nodes": row["nodes"]}) + try: + found = self._run(self._app.lookup(query, wide=wide), timeout=_SEARCH_TIMEOUT) + except Exception: + found = [] + for row in found: + results.append({"kind": "node", "node": row["id"], "pseudo": row["pseudo"], + "saved": row["id"] in known or bool(self.state.find(row["id"]))}) + return {"results": results} + + # -- what the page does ------------------------------------------------- + + def handle(self, op: str, data: dict) -> tuple[int, dict]: + """One call from the page. ``(status, document)``; never raises.""" + method = getattr(self, "do_" + re.sub(r"[^a-z_]", "", op or ""), None) + if method is None or not isinstance(data, dict): + return 404, {"error": "not found"} + try: + return method(data) + except asyncio.TimeoutError: + return 504, {"error": "the node did not answer in time"} + except Exception as exc: # noqa: BLE001 — a bad call must not be a crash + return 400, {"error": str(exc)[:200] or type(exc).__name__} + + @staticmethod + def _fail(why: str, status: int = 400) -> tuple[int, dict]: + return status, {"ok": False, "error": _CHANGES.get(why, why), "why": why} + + def do_save(self, data: dict): + book, cid = data.get("book", ""), data.get("id") or None + if not cs.is_book_id(book) or (cid is not None and not cs.is_cid(cid)): + return self._fail("no_book") + ok, result = self.state.save(book, {k: data[k] for k in ("name", "nodes", "note", "fav") + if k in data}, cid) + if not ok: + return self._fail(result) + self._later(self._app.after_change(book, [result["id"]])) + self._later(self._app.refresh_names(force=True)) + return 200, {"ok": True, "contact": result} + + def do_delete(self, data: dict): + book, cid = data.get("book", ""), data.get("id", "") + if not cs.is_book_id(book) or not cs.is_cid(cid): + return self._fail("no_contact") + ok, why = self.state.remove(book, cid) + if not ok: + return self._fail(why) + self._later(self._app.after_change(book, [cid])) + return 200, {"ok": True} + + def do_move(self, data: dict): + """Copy a contact into another book of mine and take it out of the first.""" + source, target, cid = data.get("book", ""), data.get("to", ""), data.get("id", "") + entry = self.state.get(source, cid) if cs.is_book_id(source) and cs.is_cid(cid) else None + if entry is None or not cs.is_book_id(target): + return self._fail("no_contact") + ok, result = self.state.save(target, {k: entry[k] for k in ("name", "nodes", "note", "fav")}) + if not ok: + return self._fail(result) + ok, why = self.state.remove(source, cid) + self._later(self._app.after_change(target, [result["id"]])) + self._later(self._app.after_change(source, [cid])) + return 200, {"ok": True, "contact": result} + + def do_book(self, data: dict): + action = data.get("action", "create") + book = data.get("book", "") + if action == "create": + created = self.state.create_book(str(data.get("name") or "Contacts"), + str(data.get("color") or "blue")) + return (200, {"ok": True, "book": created}) if created else self._fail("full") + if not cs.is_book_id(book): + return self._fail("no_book") + if action == "rename": + ok = self.state.set_book(book, name=data.get("name"), color=data.get("color")) + if ok: + self._later(self._app.after_change(book, [])) + return (200, {"ok": True}) if ok else self._fail("read_only") + if action == "default": + return (200, {"ok": True}) if self.state.set_default(book) else self._fail("read_only") + if action == "delete": + done = self._run(self._app.remove_book(book)) + return (200, {"ok": True}) if done else self._fail("no_book") + return 400, {"error": "unknown action"} + + def do_share(self, data: dict): + book, node, role = data.get("book", ""), data.get("node", ""), data.get("role", "read") + if not cs.is_book_id(book): + return self._fail("no_book") + meta = self.state.book(book) + if meta is None or meta["role"] != cs.OWNER: + return self._fail("not_owner") + if not cs.is_node(node) or role not in cs.ROLES: + return self._fail("bad_node") + if self._app.node_id is not None and node == self._app.node_id.raw.hex(): + return self._fail("self") + ok = self._run(self._app.offer(book, node, role)) + # The invitation is kept when the first send fails: it goes again with + # the next round, which is what a node that is offline needs. + return 200, {"ok": True, "sent": bool(ok)} + + def do_unshare(self, data: dict): + book, node = data.get("book", ""), data.get("node", "") + if not cs.is_book_id(book) or not cs.is_node(node): + return self._fail("no_book") + done = self._run(self._app.revoke(book, node)) + return (200, {"ok": True}) if done else self._fail("no_book") + + def do_answer(self, data: dict): + book, sender = data.get("book", ""), data.get("from", "") + if not cs.is_book_id(book) or not cs.is_node(sender): + return self._fail("no_book") + done = self._run(self._app.respond(book, sender, data.get("accept") is True)) + return (200, {"ok": True}) if done else self._fail("gone") + + def do_people(self, data: dict): + return 200, self.people(str(data.get("query", "")), wide=data.get("wide") is not False) + + def do_import(self, data: dict): + book, text = data.get("book", ""), data.get("text", "") + if not cs.is_book_id(book) or not isinstance(text, str): + return self._fail("no_book") + if len(text) > MAX_IMPORT: + return 413, {"error": "that file is too large"} + added = skipped = 0 + for fields in parse_vcards(text)[:MAX_IMPORT_CARDS]: + ok, result = self.state.save(book, fields) + if ok: + added += 1 + else: + skipped += 1 + if result in ("read_only", "no_book", "full"): + break + if added: + self._later(self._app.after_change(book, [r["id"] for r in self.state.entries(book)])) + return 200, {"ok": True, "added": added, "skipped": skipped} + + def export(self, book: str) -> tuple[str, str] | None: + """``(file name, vCard text)`` for one book, or ``None``.""" + meta = self.state.book(book) if cs.is_book_id(book) else None + if meta is None: + return None + return (re.sub(r"[^A-Za-z0-9._-]+", "_", meta["name"]).strip("_") or "contacts") + ".vcf", \ + vcards(self.state.entries(book)) + + +# --------------------------------------------------------------------------- +# vCard: the one format every address book can read +# --------------------------------------------------------------------------- + +def _esc(text: str) -> str: + return (text.replace("\\", "\\\\").replace("\n", "\\n") + .replace(",", "\\,").replace(";", "\\;")) + + +def _unesc(text: str) -> str: + out, i = [], 0 + while i < len(text): + if text[i] == "\\" and i + 1 < len(text): + out.append("\n" if text[i + 1] in "nN" else text[i + 1]) + i += 2 + else: + out.append(text[i]) + i += 1 + return "".join(out) + + +def vcards(entries) -> str: + lines = [] + for entry in entries: + lines += ["BEGIN:VCARD", "VERSION:3.0", + "FN:" + _esc(entry["name"] or entry["nodes"][0]["id"][:12]), + "N:" + _esc(entry["name"]) + ";;;;"] + if entry["note"]: + lines.append("NOTE:" + _esc(entry["note"])) + if entry["fav"]: + lines.append("X-NMESH-FAVORITE:1") + for node in entry["nodes"]: + label = ";X-LABEL=" + _esc(node["label"]).replace(":", " ") if node["label"] else "" + lines.append("X-NMESH-NODE" + label + ":" + node["id"]) + lines.append("END:VCARD") + return "\r\n".join(lines) + ("\r\n" if lines else "") + + +def parse_vcards(text: str) -> list[dict]: + """Contacts from vCard text. Lenient about what other address books write, + strict about what is kept: only a name, a note and the nodes survive.""" + unfolded = re.sub(r"\r?\n[ \t]", "", text) + cards, current = [], None + for raw in unfolded.splitlines(): + line = raw.strip() + upper = line.upper() + if upper == "BEGIN:VCARD": + current = {"name": "", "nodes": [], "note": "", "fav": False} + elif upper == "END:VCARD": + if current is not None and (current["name"] or current["nodes"]): + cards.append(current) + current = None + elif current is not None and ":" in line: + head, _, value = line.partition(":") + key = head.split(";")[0].upper().split(".")[-1] + if key == "FN": + current["name"] = _unesc(value) + elif key == "N" and not current["name"]: + current["name"] = " ".join(p for p in reversed( + [_unesc(x) for x in value.split(";")[:2]]) if p) + elif key == "NOTE": + current["note"] = _unesc(value) + elif key == "X-NMESH-FAVORITE": + current["fav"] = value.strip() == "1" + elif key == "X-NMESH-NODE" and cs.is_node(value.strip().lower()): + label = re.search(r"X-LABEL=([^;:]*)", head, re.IGNORECASE) + current["nodes"].append({"id": value.strip().lower(), + "label": _unesc(label.group(1)) if label else ""}) + if len(cards) >= MAX_IMPORT_CARDS: + break + return cards diff --git a/src/apps/fleet.py b/src/apps/fleet.py index 164c0e5b..84657484 100644 --- a/src/apps/fleet.py +++ b/src/apps/fleet.py @@ -3344,13 +3344,15 @@ class ConsoleProxyTimeout(ConsoleProxyError): # the console's own surface and already behind its session — except three # families, each opened by a grant of its own and by nothing less: # -# /api/chat/ — someone else's conversations are not part of managing their -# node: `apps` is the grant that says they are. +# /api/chat/ and /api/contacts/ +# — someone else's conversations, and the people they know, are +# not part of managing their node: `apps` is the grant that +# says they are. # /api/fleet/ — a managed node is not a jump host. Without `full`, one grant # would chain into every node *that* node manages. # /api/remote/ — the proxy driving the proxy, which is `full`'s too: the # operator reaching on through this node. -_NEEDS_APPS = ("/api/chat/",) +_NEEDS_APPS = ("/api/chat/", "/api/contacts/") _NEEDS_FULL = ("/api/fleet/", "/api/remote/") diff --git a/src/control/modules/apps.py b/src/control/modules/apps.py index 4b462194..48fad263 100644 --- a/src/control/modules/apps.py +++ b/src/control/modules/apps.py @@ -115,8 +115,8 @@ def _reachable(self, origin: str, app: str, row: dict) -> bool: if row.get("operator"): return False # a person's to call, never an app's perms = self._context.provided("perms") - need = "control.apps" if row.get("changes") else "readstate.apps" - return perms is not None and perms.allows(app_hex, need) + return perms is not None and perms.allows( + app_hex, app_perms.app_call_need(row)) if carries_apps(origin): return True # Every other console is at a distance and holds no `apps` grant — @@ -351,7 +351,7 @@ def endpoint_rows(context) -> list: "params": row.get("params", []), "changes": row.get("changes", False), "reach": "remote" if row.get("remote") else "local", "background": False, - "permission": "control.apps" if row.get("changes") else "readstate.apps", + "permission": app_perms.app_call_need(row), "apps": True, "tool": "app_" + tool_name(op), "modded_by": "", }) web = context.provided("web") diff --git a/src/locales/de.json b/src/locales/de.json index 3400105e..23640572 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1,8 +1,125 @@ { + "chat.attach": "Datei anhängen", + "chat.back": "Zurück zur Liste", + "chat.blank.hint": "Wähle links jemanden aus oder starte einen neuen Chat. Alles, was du sendest, ist zwischen den beiden Knoten Ende-zu-Ende-verschlüsselt.", + "chat.blank.start": "Chat starten", + "chat.blank.title": "Keine Unterhaltung geöffnet", + "chat.contacts.add": "Zu Kontakten hinzufügen", + "chat.contacts.allow": "Erlauben und hinzufügen", + "chat.contacts.ask.body": "Er fügt Personen deinem Standard-Adressbuch hinzu.", + "chat.contacts.ask.title": "Der Chat braucht die Erlaubnis, Kontakte zu speichern", + "chat.contacts.manage": "Kontakte verwalten", + "chat.contacts.refused": "Diese Berechtigung konnte nicht erteilt werden.", + "chat.contacts.saved": "{name} ist jetzt in deinen Kontakten.", + "chat.contacts.unavailable": "Die Kontakte-App läuft auf diesem Knoten nicht.", + "chat.ctx_local": "Der Chat läuft auf diesem Knoten – der andere hat ihm seine Apps nicht freigegeben.", + "chat.delete.body": "Sie wird auf jedem Knoten, der sie hat, durch einen Löschvermerk ersetzt.", + "chat.delete.title": "Diese Nachricht für alle löschen?", + "chat.delete_conv": "Unterhaltung löschen", + "chat.deleted": "gelöschte Nachricht", + "chat.drop.body": "Die Unterhaltung verschwindet von diesem Knoten.", + "chat.drop.done": "Unterhaltung entfernt", + "chat.drop.rewrite": "Die Person kann dir weiterhin schreiben. Deine Kontakte bleiben unberührt.", + "chat.drop.title": "Diese Unterhaltung entfernen?", + "chat.edit.title": "Diese Nachricht bearbeiten", + "chat.edited": "bearbeitet", + "chat.emoji": "Emoji", + "chat.empty.hint": "Starte eine mit dem Stift oben oder über deine Kontakte.", + "chat.empty.title": "Noch keine Unterhaltung", + "chat.err.edit": "Diese Änderung wurde nicht gespeichert.", + "chat.err.file_not_sent": "{name} wurde nicht gesendet.", + "chat.err.group": "Eine Gruppe braucht einen Namen und ein Mitglied.", + "chat.err.name": "Dieser Name kann nicht verwendet werden.", + "chat.err.node_id": "Eine Knoten-ID besteht aus 40 Hexadezimalzeichen.", + "chat.err.not_sent": "Diese Nachricht wurde nicht gesendet – sie steht wieder im Eingabefeld.", + "chat.err.unreadable": "Diese Datei konnte nicht gelesen werden.", + "chat.file": "Datei", + "chat.group": "Gruppe", + "chat.group.left": "Gruppe verlassen", + "chat.group.nobody": "Füge zuerst Kontakte hinzu.", + "chat.jump": "Neue Nachrichten", + "chat.leave.body": "Die anderen Mitglieder behalten ihre.", + "chat.leave.confirm": "Gruppe verlassen", + "chat.leave.title": "Diese Gruppe verlassen und löschen?", + "chat.login.hint": "Der Chat nutzt das Konsolen-Passwort dieses Knotens. Nachrichten sind Ende-zu-Ende-verschlüsselt; Relays transportieren sie, ohne sie lesen zu können.", + "chat.members": { + "one": "{count} Mitglied", + "other": "{count} Mitglieder" + }, + "chat.message": "Nachricht", + "chat.messages": "Nachrichten", + "chat.nc.by_name": "Nach Name suchen…", + "chat.nc.create": "Gruppe erstellen", + "chat.nc.group": "Neue Gruppe", + "chat.nc.group_name": "Gruppenname", + "chat.nc.members": "Mitglieder", + "chat.nc.paste": "Oder füge eine Knoten-ID ein", + "chat.nc.people": "Personen finden", + "chat.nc.start": "Starten", + "chat.new": "Neuer Chat", + "chat.node": "Knoten", + "chat.nomatch.hint": "Versuche einen kürzeren Namen oder füge eine Knoten-ID ein.", + "chat.nomatch.title": "Nichts passt dazu", + "chat.palette": "Befehlspalette", + "chat.palette_ph": "Aktion ausführen…", + "chat.people.empty.hint": "Füge jemanden aus einer Unterhaltung hinzu oder verwalte deine Adressbücher unter Kontakte.", + "chat.people.empty.title": "Noch keine Kontakte", + "chat.people.locked.hint": "Erlaube dem Chat, deine Kontakte zu nutzen, um sie hier zu sehen.", + "chat.people.locked.title": "Kontakte sind für den Chat aus", + "chat.perm.allow": "Erlauben", + "chat.perm.body": "Der Chat findet Personen über die Namen, die du ihnen gegeben hast, und kann neue speichern. Es wird nichts mit anderen geteilt.", + "chat.perm.no": "Nicht jetzt", + "chat.perm.read": "Nur lesen", + "chat.perm.title": "Dem Chat Zugriff auf Kontakte erlauben?", + "chat.photo": "Foto", + "chat.prefs": "Einstellungen", + "chat.prefs.details": "Knoten-Details einer Person öffnen", + "chat.prefs.details_hint": "Ein Tipp auf den Namen oben in einer Unterhaltung zeigt, was dieser Knoten über die Identität weiß – die Verbindung, ihre Adressen und was ihr einander erlaubt.", + "chat.prefs.panel": "Neben der Unterhaltung", + "chat.prefs.tab": "In einem neuen Tab", + "chat.prefs.window": "In einem eigenen Fenster", + "chat.profile": "Dein Profil", + "chat.profile.bio": "Über mich", + "chat.profile.bio_ph": "Ein paar Worte über dich", + "chat.profile.id": "Deine Knoten-ID", + "chat.profile.id_hint": "Das braucht jemand anderes, um einen Chat mit dir zu starten.", + "chat.profile.name": "Anzeigename", + "chat.profile.name_hint": "Das ist der Name deines Knotens, überall sichtbar, wo er erscheint – nicht nur im Chat.", + "chat.profile.name_ph": "Dein Name", + "chat.profile.photo": "Foto ändern", + "chat.profile.save": "Profil speichern", + "chat.profile.saved": "Profil gespeichert", + "chat.react": "Reagieren", + "chat.reply": "Antworten", + "chat.reply_cancel": "Antwort abbrechen", + "chat.search.label": "Chats und Personen suchen", + "chat.search.none": "Niemand gefunden.", + "chat.search.nopeople": "Keine Personen gefunden.", + "chat.search.people": "Personen", + "chat.search.placeholder": "Chats und Personen suchen…", + "chat.searching": "Suche…", + "chat.set_name": "Name festlegen", + "chat.side": "Unterhaltungen", + "chat.skip": "Zur Unterhaltung springen", + "chat.tab.chats": "Chats", + "chat.tab.people": "Kontakte", + "chat.title": "Chat", + "chat.typing": "schreibt…", + "chat.voice.cancel": "Aufnahme verwerfen", + "chat.voice.denied": "Das Mikrofon ist nicht verfügbar. Erlaube es im Browser, um aufzunehmen.", + "chat.voice.failed": "Diese Sprachnachricht konnte nicht abgespielt werden.", + "chat.voice.message": "Sprachnachricht", + "chat.voice.play": "Abspielen oder pausieren", + "chat.voice.record": "Sprachnachricht aufnehmen", + "chat.voice.recording": "Aufnahme", + "chat.voice.seek": "Position in der Sprachnachricht", + "chat.you": "Du", "common.add": "Hinzufügen", "common.back": "Zurück", + "common.back_to_console": "Zurück zur Konsole", "common.cancel": "Abbrechen", "common.close": "Schließen", + "common.confirm": "Bestätigen", "common.copy": "Kopieren", "common.delete": "Löschen", "common.edit": "Bearbeiten", @@ -16,6 +133,7 @@ "common.saved": "Gespeichert", "common.search": "Suchen", "common.send": "Senden", + "common.theme": "Design wechseln", "common.unknown": "Unbekannt", "config.saved": "Konfiguration gespeichert", "console.100_rows": "100 Zeilen", @@ -556,6 +674,113 @@ "console.zoom": "Zoom", "console.zoom_in": "Vergrößern", "console.zoom_out": "Verkleinern", + "contacts.add": "Kontakt hinzufügen", + "contacts.blank.hint": "Wähle einen Kontakt oder füge einen hinzu. Ein Kontakt ist ein Name, den du gewählt hast, und die Knoten, zu denen er gehört – Chat und alle anderen Apps finden darüber Personen.", + "contacts.blank.title": "Kein Kontakt geöffnet", + "contacts.book.color": "Farbe", + "contacts.book.default": "Zum Standard-Adressbuch machen", + "contacts.book.delete": "Dieses Adressbuch löschen", + "contacts.book.delete_body": "„{name}“ und seine Kontakte werden von diesem Knoten entfernt. Knoten, mit denen es geteilt ist, behalten ihre Kopie als eigenes Adressbuch.", + "contacts.book.delete_title": "Dieses Adressbuch löschen?", + "contacts.book.leave": "Nicht mehr folgen", + "contacts.book.leave_body": "„{name}“ wird von diesem Knoten entfernt. Der Besitzer behält es.", + "contacts.book.leave_title": "Diesem Adressbuch nicht mehr folgen?", + "contacts.book.more": "Optionen des Adressbuchs", + "contacts.book.name": "Name", + "contacts.book.new": "Neues Adressbuch", + "contacts.book.private": "Nur auf diesem Knoten", + "contacts.book.rename": "Umbenennen oder umfärben", + "contacts.book.shared_edit": "Mit dir geteilt, du kannst bearbeiten", + "contacts.book.shared_out": "Von dir geteilt", + "contacts.book.shared_read": "Mit dir geteilt, nur lesen", + "contacts.chat": "Nachricht senden", + "contacts.color.amber": "Bernstein", + "contacts.color.blue": "Blau", + "contacts.color.gray": "Grau", + "contacts.color.green": "Grün", + "contacts.color.pink": "Rosa", + "contacts.color.purple": "Violett", + "contacts.color.red": "Rot", + "contacts.color.teal": "Türkis", + "contacts.ctx_local": "Kontakte läuft auf diesem Knoten – der andere hat ihm seine Apps nicht freigegeben.", + "contacts.delete.body": "{name} wird aus diesem Adressbuch entfernt, auch auf Knoten, mit denen es geteilt ist.", + "contacts.delete.title": "Diesen Kontakt löschen?", + "contacts.empty.hint": "Füge jemanden nach Namen hinzu oder finde ihn im Netzwerk.", + "contacts.empty.title": "Dieses Adressbuch ist leer", + "contacts.err.bad_node": "Das ist keine Knoten-ID.", + "contacts.err.empty": "Ein Kontakt braucht einen Namen oder einen Knoten.", + "contacts.err.full": "Dieses Adressbuch ist voll.", + "contacts.err.gone": "Diese Einladung gibt es nicht mehr.", + "contacts.err.no_book": "Dieses Adressbuch gibt es nicht.", + "contacts.err.no_contact": "Diesen Kontakt gibt es nicht.", + "contacts.err.not_owner": "Nur der Knoten, der ein Adressbuch angelegt hat, kann es teilen.", + "contacts.err.permission": "Diese App hat dafür keine Berechtigung erhalten.", + "contacts.err.read_only": "Dieses Adressbuch ist nur zum Lesen mit dir geteilt.", + "contacts.err.self": "Das ist dieser Knoten.", + "contacts.export": "Als vCard exportieren", + "contacts.favourite": "Favorit", + "contacts.import": "vCard-Datei importieren", + "contacts.import.done": { + "one": "{count} Kontakt importiert", + "other": "{count} Kontakte importiert" + }, + "contacts.import.skipped": { + "one": "{count} Eintrag übersprungen", + "other": "{count} Einträge übersprungen" + }, + "contacts.login.hint": "Kontakte nutzt das Konsolen-Passwort dieses Knotens.", + "contacts.move": "In ein anderes Adressbuch verschieben", + "contacts.move.keep": "Hier lassen", + "contacts.name": "Name", + "contacts.name.placeholder": "Wer ist das?", + "contacts.new": "Neuer Kontakt", + "contacts.no_node": "Noch kein Knoten", + "contacts.node.add": "Knoten hinzufügen", + "contacts.node.id": "Knoten-ID", + "contacts.node.known": "Der Knoten nennt ihn {name}", + "contacts.node.label": "Bezeichnung (Handy, Arbeit…)", + "contacts.nodes": "Knoten", + "contacts.nodes.hint": "Eine Person kann mehrere Knoten nutzen. Die Knoten-ID identifiziert sie wirklich; der Name ist deiner.", + "contacts.nomatch.hint": "Prüfe die Schreibweise oder nutze „Kontakt hinzufügen“, um das ganze Netzwerk zu durchsuchen.", + "contacts.nomatch.title": "Kein Kontakt passt", + "contacts.none.hint": "Erstelle eines mit dem Plus oben.", + "contacts.none.title": "Kein Adressbuch", + "contacts.note": "Notiz", + "contacts.note.placeholder": "Nur du und die Leser dieses Adressbuchs sehen sie", + "contacts.offer.accept": "Annehmen", + "contacts.offer.decline": "Ablehnen", + "contacts.offer.from": "Von {who}", + "contacts.people.byid": "Oder per Knoten-ID hinzufügen", + "contacts.people.label": "Im Netzwerk nach Namen suchen", + "contacts.people.none": "Niemand hört auf diesen Namen", + "contacts.people.none_hint": "Namen werden im ganzen Netzwerk gesucht. Versuche eine andere Schreibweise oder füge eine Knoten-ID ein.", + "contacts.people.placeholder": "Ein Name, auch ein Teil davon…", + "contacts.people.saved": "Gespeichert", + "contacts.people.title": "Personen finden", + "contacts.role.read": "Darf lesen", + "contacts.role.write": "Darf bearbeiten", + "contacts.search.label": "In diesem Adressbuch suchen", + "contacts.search.placeholder": "Nach Name oder Knoten-ID suchen…", + "contacts.share.active": "Folgt diesem Adressbuch", + "contacts.share.add": "Knoten einladen", + "contacts.share.hint": "Ein anderer Knoten erhält dieses Adressbuch erst, wenn sein Besitzer annimmt. Du kannst es jederzeit zurücknehmen; seine Kopie wird dann ein eigenes Adressbuch.", + "contacts.share.invite": "Einladen", + "contacts.share.make_read": "Auf Lesen beschränken", + "contacts.share.make_write": "Bearbeiten erlauben", + "contacts.share.nobody": "Mit niemandem geteilt.", + "contacts.share.node": "Name oder Knoten-ID", + "contacts.share.pending": "Wartet auf Antwort", + "contacts.share.queued": "Dieser Knoten hat nicht geantwortet. Die Einladung bleibt erhalten und wird später erneut gesendet.", + "contacts.share.revoke": "Zurücknehmen", + "contacts.share.role": "Was er darf", + "contacts.share.sent": "Einladung gesendet.", + "contacts.share.title": "Dieses Adressbuch teilen", + "contacts.skip": "Zum Kontakt springen", + "contacts.title": "Kontakte", + "count.contact": { + "one": "{count} Kontakt", + "other": "{count} Kontakte" + }, "count.container": { "one": "{count} Container", "other": "{count} Container" @@ -844,6 +1069,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "Eintreten", + "login.failed": "Anmeldung fehlgeschlagen", + "login.password": "Konsolen-Passwort", + "login.title": "Anmelden", + "login.unreachable": "Konsole nicht erreichbar", "node.1_hour": "1 Stunde", "node.5_minutes": "5 Minuten", "node.6_hours": "6 Stunden", @@ -861,6 +1091,8 @@ "node.state": "Status", "node.this_page_reads_the_console_of_this": "Diese Seite liest die Konsole dieses Knotens und braucht daher das Konsolenpasswort.", "node.tried": "Versucht", + "palette.action": "Aktion", + "palette.goto": "Gehe zu", "pkg.downloaded": "{path} heruntergeladen.", "pkg.fetching_and_verifying": "Rufe ab und prüfe…", "pkg.fetching_the_package": "Rufe das Paket ab…", diff --git a/src/locales/en.json b/src/locales/en.json index aa0139fc..a309dd99 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,8 +1,125 @@ { + "chat.attach": "Attach a file", + "chat.back": "Back to the list", + "chat.blank.hint": "Pick someone on the left, or start a new chat. Everything you send is end-to-end encrypted between the two nodes.", + "chat.blank.start": "Start a chat", + "chat.blank.title": "No conversation open", + "chat.contacts.add": "Add to contacts", + "chat.contacts.allow": "Allow and add", + "chat.contacts.ask.body": "It will add people to your default address book.", + "chat.contacts.ask.title": "Chat needs permission to save contacts", + "chat.contacts.manage": "Manage contacts", + "chat.contacts.refused": "That permission could not be given.", + "chat.contacts.saved": "{name} is now in your contacts.", + "chat.contacts.unavailable": "The Contacts app is not running on this node.", + "chat.ctx_local": "Chat runs on this node — that one has not granted this one its apps.", + "chat.delete.body": "It is replaced by a tombstone on every node that has it.", + "chat.delete.title": "Delete this message for everyone?", + "chat.delete_conv": "Delete conversation", + "chat.deleted": "deleted message", + "chat.drop.body": "The conversation disappears from this node.", + "chat.drop.done": "Conversation removed", + "chat.drop.rewrite": "They can still write to you again. Your contacts are not touched.", + "chat.drop.title": "Remove this conversation?", + "chat.edit.title": "Edit this message", + "chat.edited": "edited", + "chat.emoji": "Emoji", + "chat.empty.hint": "Start one with the pencil above, or from your contacts.", + "chat.empty.title": "No conversation yet", + "chat.err.edit": "That edit was not saved.", + "chat.err.file_not_sent": "{name} was not sent.", + "chat.err.group": "A group needs a name and a member.", + "chat.err.name": "That name cannot be used.", + "chat.err.node_id": "A node id is 40 hexadecimal characters.", + "chat.err.not_sent": "That message was not sent — it is back in the box.", + "chat.err.unreadable": "That file could not be read.", + "chat.file": "File", + "chat.group": "Group", + "chat.group.left": "Group left", + "chat.group.nobody": "Add contacts first.", + "chat.jump": "New messages", + "chat.leave.body": "The other members keep theirs.", + "chat.leave.confirm": "Leave group", + "chat.leave.title": "Leave and delete this group?", + "chat.login.hint": "Chat uses the console password of this node. Messages are end-to-end encrypted; relays carry them without being able to read them.", + "chat.members": { + "one": "{count} member", + "other": "{count} members" + }, + "chat.message": "Message", + "chat.messages": "Messages", + "chat.nc.by_name": "Search by name…", + "chat.nc.create": "Create group", + "chat.nc.group": "New group", + "chat.nc.group_name": "Group name", + "chat.nc.members": "Members", + "chat.nc.paste": "Or paste a node id", + "chat.nc.people": "Find people", + "chat.nc.start": "Start", + "chat.new": "New chat", + "chat.node": "Node", + "chat.nomatch.hint": "Try a shorter name, or paste a node id.", + "chat.nomatch.title": "Nothing matches that", + "chat.palette": "Command palette", + "chat.palette_ph": "Run an action…", + "chat.people.empty.hint": "Add someone from a conversation, or manage your address books in Contacts.", + "chat.people.empty.title": "No contacts yet", + "chat.people.locked.hint": "Allow chat to use your contacts to see them here.", + "chat.people.locked.title": "Contacts are off for chat", + "chat.perm.allow": "Allow", + "chat.perm.body": "Chat finds people by the names you gave them and can save new ones. Nothing is shared with anyone.", + "chat.perm.no": "Not now", + "chat.perm.read": "Read only", + "chat.perm.title": "Let chat use your contacts?", + "chat.photo": "Photo", + "chat.prefs": "Preferences", + "chat.prefs.details": "Opening someone's node details", + "chat.prefs.details_hint": "Tapping the name at the top of a conversation shows what this node knows about that identity — the link, its addresses, and what each of you may do to the other.", + "chat.prefs.panel": "Beside the conversation", + "chat.prefs.tab": "In a new tab", + "chat.prefs.window": "In a separate window", + "chat.profile": "Your profile", + "chat.profile.bio": "Bio", + "chat.profile.bio_ph": "A few words about you", + "chat.profile.id": "Your node id", + "chat.profile.id_hint": "This is what someone else needs to start a chat with you.", + "chat.profile.name": "Display name", + "chat.profile.name_hint": "This is your node's name, shown everywhere it appears — not just in chat.", + "chat.profile.name_ph": "Your name", + "chat.profile.photo": "Change photo", + "chat.profile.save": "Save profile", + "chat.profile.saved": "Profile saved", + "chat.react": "React", + "chat.reply": "Reply", + "chat.reply_cancel": "Cancel reply", + "chat.search.label": "Search chats and people", + "chat.search.none": "No one found.", + "chat.search.nopeople": "No people found.", + "chat.search.people": "People", + "chat.search.placeholder": "Search chats and people…", + "chat.searching": "Searching…", + "chat.set_name": "Set your name", + "chat.side": "Conversations", + "chat.skip": "Skip to the conversation", + "chat.tab.chats": "Chats", + "chat.tab.people": "Contacts", + "chat.title": "Chat", + "chat.typing": "typing…", + "chat.voice.cancel": "Discard the recording", + "chat.voice.denied": "The microphone is not available. Allow it in the browser to record.", + "chat.voice.failed": "That voice message could not be played.", + "chat.voice.message": "Voice message", + "chat.voice.play": "Play or pause", + "chat.voice.record": "Record a voice message", + "chat.voice.recording": "Recording", + "chat.voice.seek": "Position in the voice message", + "chat.you": "You", "common.add": "Add", "common.back": "Back", + "common.back_to_console": "Back to console", "common.cancel": "Cancel", "common.close": "Close", + "common.confirm": "Confirm", "common.copy": "Copy", "common.delete": "Delete", "common.edit": "Edit", @@ -16,6 +133,7 @@ "common.saved": "Saved", "common.search": "Search", "common.send": "Send", + "common.theme": "Switch theme", "common.unknown": "Unknown", "config.saved": "Configuration saved", "console.100_rows": "100 rows", @@ -556,6 +674,113 @@ "console.zoom": "Zoom", "console.zoom_in": "Zoom in", "console.zoom_out": "Zoom out", + "contacts.add": "Add a contact", + "contacts.blank.hint": "Pick a contact, or add one. A contact is a name you chose and the nodes it answers to — chat and every other app find people through it.", + "contacts.blank.title": "No contact open", + "contacts.book.color": "Colour", + "contacts.book.default": "Make this the default book", + "contacts.book.delete": "Delete this book", + "contacts.book.delete_body": "“{name}” and its contacts are removed from this node. Nodes it is shared with keep their copy as a book of their own.", + "contacts.book.delete_title": "Delete this address book?", + "contacts.book.leave": "Stop following", + "contacts.book.leave_body": "“{name}” is removed from this node. Its owner keeps it.", + "contacts.book.leave_title": "Stop following this address book?", + "contacts.book.more": "Address book options", + "contacts.book.name": "Name", + "contacts.book.new": "New address book", + "contacts.book.private": "Only on this node", + "contacts.book.rename": "Rename or recolour", + "contacts.book.shared_edit": "Shared with you, you can edit", + "contacts.book.shared_out": "Shared by you", + "contacts.book.shared_read": "Shared with you, read only", + "contacts.chat": "Message", + "contacts.color.amber": "Amber", + "contacts.color.blue": "Blue", + "contacts.color.gray": "Grey", + "contacts.color.green": "Green", + "contacts.color.pink": "Pink", + "contacts.color.purple": "Purple", + "contacts.color.red": "Red", + "contacts.color.teal": "Teal", + "contacts.ctx_local": "Contacts runs on this node — that one has not granted this one its apps.", + "contacts.delete.body": "{name} is removed from this book, and from nodes it is shared with.", + "contacts.delete.title": "Delete this contact?", + "contacts.empty.hint": "Add someone by name, or find them on the network.", + "contacts.empty.title": "This book is empty", + "contacts.err.bad_node": "That is not a node id.", + "contacts.err.empty": "A contact needs a name or a node.", + "contacts.err.full": "This book is full.", + "contacts.err.gone": "That invitation is gone.", + "contacts.err.no_book": "That address book does not exist.", + "contacts.err.no_contact": "That contact does not exist.", + "contacts.err.not_owner": "Only the node that made a book can share it.", + "contacts.err.permission": "This app was not given the permission for that.", + "contacts.err.read_only": "This book is shared with you to read only.", + "contacts.err.self": "That is this node.", + "contacts.export": "Export as vCard", + "contacts.favourite": "Favourite", + "contacts.import": "Import a vCard file", + "contacts.import.done": { + "one": "{count} contact imported", + "other": "{count} contacts imported" + }, + "contacts.import.skipped": { + "one": "{count} entry skipped", + "other": "{count} entries skipped" + }, + "contacts.login.hint": "Contacts uses the console password of this node.", + "contacts.move": "Move to another book", + "contacts.move.keep": "Keep it here", + "contacts.name": "Name", + "contacts.name.placeholder": "Who is this?", + "contacts.new": "New contact", + "contacts.no_node": "No node yet", + "contacts.node.add": "Add a node", + "contacts.node.id": "Node id", + "contacts.node.known": "The node calls it {name}", + "contacts.node.label": "Label (phone, work…)", + "contacts.nodes": "Nodes", + "contacts.nodes.hint": "A person may use several nodes. The node id is what really identifies them; the name is yours.", + "contacts.nomatch.hint": "Check the spelling, or use Add a contact to search the whole network.", + "contacts.nomatch.title": "No contact matches", + "contacts.none.hint": "Make one with the plus above.", + "contacts.none.title": "No address book", + "contacts.note": "Note", + "contacts.note.placeholder": "Only you and this book's readers see it", + "contacts.offer.accept": "Accept", + "contacts.offer.decline": "Decline", + "contacts.offer.from": "From {who}", + "contacts.people.byid": "Or add by node id", + "contacts.people.label": "Search the network by name", + "contacts.people.none": "No one answers to that name", + "contacts.people.none_hint": "Names are checked across the whole network. Try another spelling, or paste a node id.", + "contacts.people.placeholder": "A name, even a part of one…", + "contacts.people.saved": "Saved", + "contacts.people.title": "Find people", + "contacts.role.read": "Can read", + "contacts.role.write": "Can edit", + "contacts.search.label": "Search this book", + "contacts.search.placeholder": "Search by name or node id…", + "contacts.share.active": "Following this book", + "contacts.share.add": "Invite a node", + "contacts.share.hint": "Another node only gets this book once its owner accepts. You can take it back at any time; its copy then becomes a book of its own.", + "contacts.share.invite": "Invite", + "contacts.share.make_read": "Make read only", + "contacts.share.make_write": "Let it edit", + "contacts.share.nobody": "Not shared with anyone.", + "contacts.share.node": "Name or node id", + "contacts.share.pending": "Waiting for an answer", + "contacts.share.queued": "That node did not answer. The invitation is kept and sent again later.", + "contacts.share.revoke": "Take back", + "contacts.share.role": "What it may do", + "contacts.share.sent": "Invitation sent.", + "contacts.share.title": "Share this book", + "contacts.skip": "Skip to the contact", + "contacts.title": "Contacts", + "count.contact": { + "one": "{count} contact", + "other": "{count} contacts" + }, "count.container": { "one": "{count} container", "other": "{count} containers" @@ -844,6 +1069,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "Enter", + "login.failed": "Login failed", + "login.password": "Console password", + "login.title": "Sign in", + "login.unreachable": "Console is not reachable", "node.1_hour": "1 hour", "node.5_minutes": "5 minutes", "node.6_hours": "6 hours", @@ -861,6 +1091,8 @@ "node.state": "State", "node.this_page_reads_the_console_of_this": "This page reads the console of this node, so it needs the console password.", "node.tried": "Tried", + "palette.action": "Action", + "palette.goto": "Go to", "pkg.downloaded": "Downloaded {path}.", "pkg.fetching_and_verifying": "Fetching and verifying…", "pkg.fetching_the_package": "Fetching the package…", diff --git a/src/locales/fr.json b/src/locales/fr.json index 943f4dda..8eeec1fa 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1,8 +1,125 @@ { + "chat.attach": "Joindre un fichier", + "chat.back": "Retour à la liste", + "chat.blank.hint": "Choisissez quelqu'un à gauche ou commencez une nouvelle conversation. Tout ce que vous envoyez est chiffré de bout en bout entre les deux nœuds.", + "chat.blank.start": "Commencer une conversation", + "chat.blank.title": "Aucune conversation ouverte", + "chat.contacts.add": "Ajouter aux contacts", + "chat.contacts.allow": "Autoriser et ajouter", + "chat.contacts.ask.body": "Il ajoutera les personnes à votre carnet par défaut.", + "chat.contacts.ask.title": "Le chat a besoin de la permission d'enregistrer des contacts", + "chat.contacts.manage": "Gérer les contacts", + "chat.contacts.refused": "Cette permission n'a pas pu être accordée.", + "chat.contacts.saved": "{name} est maintenant dans vos contacts.", + "chat.contacts.unavailable": "L'app Contacts ne tourne pas sur ce nœud.", + "chat.ctx_local": "Le chat tourne sur ce nœud : l'autre ne lui a pas accordé ses apps.", + "chat.delete.body": "Elle est remplacée par une pierre tombale sur chaque nœud qui l'a.", + "chat.delete.title": "Supprimer ce message pour tout le monde ?", + "chat.delete_conv": "Supprimer la conversation", + "chat.deleted": "message supprimé", + "chat.drop.body": "La conversation disparaît de ce nœud.", + "chat.drop.done": "Conversation supprimée", + "chat.drop.rewrite": "La personne peut toujours vous réécrire. Vos contacts ne sont pas touchés.", + "chat.drop.title": "Supprimer cette conversation ?", + "chat.edit.title": "Modifier ce message", + "chat.edited": "modifié", + "chat.emoji": "Émoji", + "chat.empty.hint": "Commencez-en une avec le crayon ci-dessus, ou depuis vos contacts.", + "chat.empty.title": "Aucune conversation", + "chat.err.edit": "Cette modification n'a pas été enregistrée.", + "chat.err.file_not_sent": "{name} n'a pas été envoyé.", + "chat.err.group": "Un groupe a besoin d'un nom et d'un membre.", + "chat.err.name": "Ce nom ne peut pas être utilisé.", + "chat.err.node_id": "Un identifiant de nœud fait 40 caractères hexadécimaux.", + "chat.err.not_sent": "Ce message n'a pas été envoyé : il est de retour dans la zone de saisie.", + "chat.err.unreadable": "Ce fichier n'a pas pu être lu.", + "chat.file": "Fichier", + "chat.group": "Groupe", + "chat.group.left": "Groupe quitté", + "chat.group.nobody": "Ajoutez d'abord des contacts.", + "chat.jump": "Nouveaux messages", + "chat.leave.body": "Les autres membres gardent la leur.", + "chat.leave.confirm": "Quitter le groupe", + "chat.leave.title": "Quitter et supprimer ce groupe ?", + "chat.login.hint": "Le chat utilise le mot de passe de console de ce nœud. Les messages sont chiffrés de bout en bout ; les relais les transportent sans pouvoir les lire.", + "chat.members": { + "one": "{count} membre", + "other": "{count} membres" + }, + "chat.message": "Message", + "chat.messages": "Messages", + "chat.nc.by_name": "Chercher par nom…", + "chat.nc.create": "Créer le groupe", + "chat.nc.group": "Nouveau groupe", + "chat.nc.group_name": "Nom du groupe", + "chat.nc.members": "Membres", + "chat.nc.paste": "Ou collez un identifiant de nœud", + "chat.nc.people": "Trouver des personnes", + "chat.nc.start": "Commencer", + "chat.new": "Nouvelle conversation", + "chat.node": "Nœud", + "chat.nomatch.hint": "Essayez un nom plus court, ou collez un identifiant de nœud.", + "chat.nomatch.title": "Rien ne correspond", + "chat.palette": "Palette de commandes", + "chat.palette_ph": "Lancer une action…", + "chat.people.empty.hint": "Ajoutez quelqu'un depuis une conversation, ou gérez vos carnets dans Contacts.", + "chat.people.empty.title": "Aucun contact", + "chat.people.locked.hint": "Autorisez le chat à utiliser vos contacts pour les voir ici.", + "chat.people.locked.title": "Les contacts sont désactivés pour le chat", + "chat.perm.allow": "Autoriser", + "chat.perm.body": "Le chat retrouve les gens par les noms que vous leur avez donnés et peut en enregistrer de nouveaux. Rien n'est partagé avec personne.", + "chat.perm.no": "Pas maintenant", + "chat.perm.read": "Lecture seule", + "chat.perm.title": "Autoriser le chat à utiliser vos contacts ?", + "chat.photo": "Photo", + "chat.prefs": "Préférences", + "chat.prefs.details": "Ouverture des détails du nœud d'une personne", + "chat.prefs.details_hint": "Toucher le nom en haut d'une conversation montre ce que ce nœud sait de cette identité : le lien, ses adresses et ce que chacun peut faire à l'autre.", + "chat.prefs.panel": "À côté de la conversation", + "chat.prefs.tab": "Dans un nouvel onglet", + "chat.prefs.window": "Dans une fenêtre séparée", + "chat.profile": "Votre profil", + "chat.profile.bio": "Bio", + "chat.profile.bio_ph": "Quelques mots sur vous", + "chat.profile.id": "Identifiant de votre nœud", + "chat.profile.id_hint": "C'est ce dont quelqu'un d'autre a besoin pour commencer une conversation avec vous.", + "chat.profile.name": "Nom affiché", + "chat.profile.name_hint": "C'est le nom de votre nœud, affiché partout où il apparaît, pas seulement dans le chat.", + "chat.profile.name_ph": "Votre nom", + "chat.profile.photo": "Changer la photo", + "chat.profile.save": "Enregistrer le profil", + "chat.profile.saved": "Profil enregistré", + "chat.react": "Réagir", + "chat.reply": "Répondre", + "chat.reply_cancel": "Annuler la réponse", + "chat.search.label": "Chercher des conversations et des personnes", + "chat.search.none": "Personne trouvé.", + "chat.search.nopeople": "Aucune personne trouvée.", + "chat.search.people": "Personnes", + "chat.search.placeholder": "Chercher des conversations et des personnes…", + "chat.searching": "Recherche…", + "chat.set_name": "Choisissez votre nom", + "chat.side": "Conversations", + "chat.skip": "Aller à la conversation", + "chat.tab.chats": "Conversations", + "chat.tab.people": "Contacts", + "chat.title": "Chat", + "chat.typing": "écrit…", + "chat.voice.cancel": "Abandonner l'enregistrement", + "chat.voice.denied": "Le micro n'est pas disponible. Autorisez-le dans le navigateur pour enregistrer.", + "chat.voice.failed": "Ce message vocal n'a pas pu être lu.", + "chat.voice.message": "Message vocal", + "chat.voice.play": "Lecture ou pause", + "chat.voice.record": "Enregistrer un message vocal", + "chat.voice.recording": "Enregistrement", + "chat.voice.seek": "Position dans le message vocal", + "chat.you": "Vous", "common.add": "Ajouter", "common.back": "Retour", + "common.back_to_console": "Retour à la console", "common.cancel": "Annuler", "common.close": "Fermer", + "common.confirm": "Confirmer", "common.copy": "Copier", "common.delete": "Supprimer", "common.edit": "Modifier", @@ -16,6 +133,7 @@ "common.saved": "Enregistré", "common.search": "Rechercher", "common.send": "Envoyer", + "common.theme": "Changer de thème", "common.unknown": "Inconnu", "config.saved": "Configuration enregistrée", "console.100_rows": "100 lignes", @@ -556,6 +674,113 @@ "console.zoom": "Zoom", "console.zoom_in": "Zoom avant", "console.zoom_out": "Zoom arrière", + "contacts.add": "Ajouter un contact", + "contacts.blank.hint": "Choisissez un contact ou ajoutez-en un. Un contact est un nom que vous avez choisi et les nœuds auxquels il répond : le chat et toutes les autres apps retrouvent les gens grâce à lui.", + "contacts.blank.title": "Aucun contact ouvert", + "contacts.book.color": "Couleur", + "contacts.book.default": "Faire de ce carnet le carnet par défaut", + "contacts.book.delete": "Supprimer ce carnet", + "contacts.book.delete_body": "« {name} » et ses contacts sont supprimés de ce nœud. Les nœuds avec lesquels il est partagé en gardent leur copie comme un carnet à eux.", + "contacts.book.delete_title": "Supprimer ce carnet ?", + "contacts.book.leave": "Ne plus suivre", + "contacts.book.leave_body": "« {name} » est supprimé de ce nœud. Son propriétaire le garde.", + "contacts.book.leave_title": "Ne plus suivre ce carnet ?", + "contacts.book.more": "Options du carnet", + "contacts.book.name": "Nom", + "contacts.book.new": "Nouveau carnet", + "contacts.book.private": "Seulement sur ce nœud", + "contacts.book.rename": "Renommer ou recolorer", + "contacts.book.shared_edit": "Partagé avec vous, modifiable", + "contacts.book.shared_out": "Partagé par vous", + "contacts.book.shared_read": "Partagé avec vous, lecture seule", + "contacts.chat": "Écrire", + "contacts.color.amber": "Ambre", + "contacts.color.blue": "Bleu", + "contacts.color.gray": "Gris", + "contacts.color.green": "Vert", + "contacts.color.pink": "Rose", + "contacts.color.purple": "Violet", + "contacts.color.red": "Rouge", + "contacts.color.teal": "Sarcelle", + "contacts.ctx_local": "Contacts tourne sur ce nœud : l'autre ne lui a pas accordé ses apps.", + "contacts.delete.body": "{name} est retiré de ce carnet, et des nœuds avec lesquels il est partagé.", + "contacts.delete.title": "Supprimer ce contact ?", + "contacts.empty.hint": "Ajoutez quelqu'un par son nom, ou retrouvez-le sur le réseau.", + "contacts.empty.title": "Ce carnet est vide", + "contacts.err.bad_node": "Ce n'est pas un identifiant de nœud.", + "contacts.err.empty": "Un contact a besoin d'un nom ou d'un nœud.", + "contacts.err.full": "Ce carnet est plein.", + "contacts.err.gone": "Cette invitation n'existe plus.", + "contacts.err.no_book": "Ce carnet n'existe pas.", + "contacts.err.no_contact": "Ce contact n'existe pas.", + "contacts.err.not_owner": "Seul le nœud qui a créé un carnet peut le partager.", + "contacts.err.permission": "Cette app n'a pas reçu la permission pour cela.", + "contacts.err.read_only": "Ce carnet est partagé avec vous en lecture seule.", + "contacts.err.self": "C'est ce nœud.", + "contacts.export": "Exporter en vCard", + "contacts.favourite": "Favori", + "contacts.import": "Importer un fichier vCard", + "contacts.import.done": { + "one": "{count} contact importé", + "other": "{count} contacts importés" + }, + "contacts.import.skipped": { + "one": "{count} entrée ignorée", + "other": "{count} entrées ignorées" + }, + "contacts.login.hint": "Contacts utilise le mot de passe de console de ce nœud.", + "contacts.move": "Déplacer vers un autre carnet", + "contacts.move.keep": "La laisser ici", + "contacts.name": "Nom", + "contacts.name.placeholder": "Qui est-ce ?", + "contacts.new": "Nouveau contact", + "contacts.no_node": "Pas encore de nœud", + "contacts.node.add": "Ajouter un nœud", + "contacts.node.id": "Identifiant du nœud", + "contacts.node.known": "Le nœud l'appelle {name}", + "contacts.node.label": "Étiquette (téléphone, travail…)", + "contacts.nodes": "Nœuds", + "contacts.nodes.hint": "Une personne peut utiliser plusieurs nœuds. L'identifiant du nœud est ce qui les identifie vraiment ; le nom est le vôtre.", + "contacts.nomatch.hint": "Vérifiez l'orthographe, ou utilisez « Ajouter un contact » pour chercher sur tout le réseau.", + "contacts.nomatch.title": "Aucun contact ne correspond", + "contacts.none.hint": "Créez-en un avec le plus ci-dessus.", + "contacts.none.title": "Aucun carnet", + "contacts.note": "Note", + "contacts.note.placeholder": "Visible par vous et les lecteurs de ce carnet", + "contacts.offer.accept": "Accepter", + "contacts.offer.decline": "Refuser", + "contacts.offer.from": "De la part de {who}", + "contacts.people.byid": "Ou ajouter par identifiant de nœud", + "contacts.people.label": "Chercher sur le réseau par nom", + "contacts.people.none": "Personne ne répond à ce nom", + "contacts.people.none_hint": "Les noms sont cherchés sur tout le réseau. Essayez une autre orthographe, ou collez un identifiant de nœud.", + "contacts.people.placeholder": "Un nom, même une partie…", + "contacts.people.saved": "Enregistré", + "contacts.people.title": "Trouver des personnes", + "contacts.role.read": "Peut lire", + "contacts.role.write": "Peut modifier", + "contacts.search.label": "Chercher dans ce carnet", + "contacts.search.placeholder": "Chercher par nom ou identifiant de nœud…", + "contacts.share.active": "Suit ce carnet", + "contacts.share.add": "Inviter un nœud", + "contacts.share.hint": "Un autre nœud ne reçoit ce carnet que lorsque son propriétaire accepte. Vous pouvez le reprendre à tout moment ; sa copie devient alors un carnet à lui.", + "contacts.share.invite": "Inviter", + "contacts.share.make_read": "Passer en lecture seule", + "contacts.share.make_write": "Autoriser la modification", + "contacts.share.nobody": "Partagé avec personne.", + "contacts.share.node": "Nom ou identifiant de nœud", + "contacts.share.pending": "En attente de réponse", + "contacts.share.queued": "Ce nœud n'a pas répondu. L'invitation est conservée et sera renvoyée plus tard.", + "contacts.share.revoke": "Reprendre", + "contacts.share.role": "Ce qu'il peut faire", + "contacts.share.sent": "Invitation envoyée.", + "contacts.share.title": "Partager ce carnet", + "contacts.skip": "Aller au contact", + "contacts.title": "Contacts", + "count.contact": { + "one": "{count} contact", + "other": "{count} contacts" + }, "count.container": { "one": "{count} conteneur", "other": "{count} conteneurs" @@ -844,6 +1069,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "Entrer", + "login.failed": "Échec de la connexion", + "login.password": "Mot de passe de la console", + "login.title": "Connexion", + "login.unreachable": "La console est injoignable", "node.1_hour": "1 heure", "node.5_minutes": "5 minutes", "node.6_hours": "6 heures", @@ -861,6 +1091,8 @@ "node.state": "État", "node.this_page_reads_the_console_of_this": "Cette page lit la console de ce nœud : elle demande donc le mot de passe de la console.", "node.tried": "Essayé", + "palette.action": "Action", + "palette.goto": "Aller à", "pkg.downloaded": "{path} téléchargé.", "pkg.fetching_and_verifying": "Récupération et vérification…", "pkg.fetching_the_package": "Récupération du paquet…", diff --git a/src/locales/pl.json b/src/locales/pl.json index 1e0aa91f..7db1a58a 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1,8 +1,127 @@ { + "chat.attach": "Dołącz plik", + "chat.back": "Wróć do listy", + "chat.blank.hint": "Wybierz kogoś po lewej albo zacznij nowy czat. Wszystko, co wysyłasz, jest szyfrowane end-to-end między dwoma węzłami.", + "chat.blank.start": "Rozpocznij czat", + "chat.blank.title": "Brak otwartej rozmowy", + "chat.contacts.add": "Dodaj do kontaktów", + "chat.contacts.allow": "Zezwól i dodaj", + "chat.contacts.ask.body": "Doda osoby do Twojej domyślnej książki adresowej.", + "chat.contacts.ask.title": "Czat potrzebuje pozwolenia na zapisywanie kontaktów", + "chat.contacts.manage": "Zarządzaj kontaktami", + "chat.contacts.refused": "Nie udało się nadać tego uprawnienia.", + "chat.contacts.saved": "{name} jest teraz w Twoich kontaktach.", + "chat.contacts.unavailable": "Aplikacja Kontakty nie działa na tym węźle.", + "chat.ctx_local": "Czat działa na tym węźle — tamten nie udzielił mu dostępu do swoich aplikacji.", + "chat.delete.body": "Na każdym węźle, który ją ma, zostanie zastąpiona znacznikiem usunięcia.", + "chat.delete.title": "Usunąć tę wiadomość dla wszystkich?", + "chat.delete_conv": "Usuń rozmowę", + "chat.deleted": "usunięta wiadomość", + "chat.drop.body": "Rozmowa zniknie z tego węzła.", + "chat.drop.done": "Rozmowa usunięta", + "chat.drop.rewrite": "Ta osoba nadal może do Ciebie napisać. Twoje kontakty nie są ruszane.", + "chat.drop.title": "Usunąć tę rozmowę?", + "chat.edit.title": "Edytuj tę wiadomość", + "chat.edited": "edytowano", + "chat.emoji": "Emoji", + "chat.empty.hint": "Zacznij ją ołówkiem powyżej albo z listy kontaktów.", + "chat.empty.title": "Brak rozmów", + "chat.err.edit": "Ta zmiana nie została zapisana.", + "chat.err.file_not_sent": "{name} nie został wysłany.", + "chat.err.group": "Grupa potrzebuje nazwy i członka.", + "chat.err.name": "Tej nazwy nie można użyć.", + "chat.err.node_id": "Identyfikator węzła to 40 znaków szesnastkowych.", + "chat.err.not_sent": "Ta wiadomość nie została wysłana — wróciła do pola.", + "chat.err.unreadable": "Nie udało się odczytać pliku.", + "chat.file": "Plik", + "chat.group": "Grupa", + "chat.group.left": "Opuszczono grupę", + "chat.group.nobody": "Najpierw dodaj kontakty.", + "chat.jump": "Nowe wiadomości", + "chat.leave.body": "Pozostali członkowie zachowują swoje.", + "chat.leave.confirm": "Opuść grupę", + "chat.leave.title": "Opuścić i usunąć tę grupę?", + "chat.login.hint": "Czat używa hasła konsoli tego węzła. Wiadomości są szyfrowane end-to-end; przekaźniki przenoszą je, nie mogąc ich odczytać.", + "chat.members": { + "one": "{count} członek", + "few": "{count} członków", + "many": "{count} członków", + "other": "{count} członka" + }, + "chat.message": "Wiadomość", + "chat.messages": "Wiadomości", + "chat.nc.by_name": "Szukaj po nazwie…", + "chat.nc.create": "Utwórz grupę", + "chat.nc.group": "Nowa grupa", + "chat.nc.group_name": "Nazwa grupy", + "chat.nc.members": "Członkowie", + "chat.nc.paste": "Albo wklej identyfikator węzła", + "chat.nc.people": "Znajdź osoby", + "chat.nc.start": "Rozpocznij", + "chat.new": "Nowy czat", + "chat.node": "Węzeł", + "chat.nomatch.hint": "Spróbuj krótszej nazwy albo wklej identyfikator węzła.", + "chat.nomatch.title": "Nic nie pasuje", + "chat.palette": "Paleta poleceń", + "chat.palette_ph": "Uruchom akcję…", + "chat.people.empty.hint": "Dodaj kogoś z rozmowy albo zarządzaj książkami adresowymi w Kontaktach.", + "chat.people.empty.title": "Brak kontaktów", + "chat.people.locked.hint": "Zezwól czatowi na użycie kontaktów, aby je tu zobaczyć.", + "chat.people.locked.title": "Kontakty są wyłączone dla czatu", + "chat.perm.allow": "Zezwól", + "chat.perm.body": "Czat znajduje osoby po nazwach, które im nadałeś, i może zapisywać nowe. Nic nie jest nikomu udostępniane.", + "chat.perm.no": "Nie teraz", + "chat.perm.read": "Tylko odczyt", + "chat.perm.title": "Zezwolić czatowi na użycie kontaktów?", + "chat.photo": "Zdjęcie", + "chat.prefs": "Preferencje", + "chat.prefs.details": "Otwieranie szczegółów węzła osoby", + "chat.prefs.details_hint": "Dotknięcie nazwy na górze rozmowy pokazuje, co ten węzeł wie o tej tożsamości: łącze, adresy i co każde z Was może robić drugiemu.", + "chat.prefs.panel": "Obok rozmowy", + "chat.prefs.tab": "W nowej karcie", + "chat.prefs.window": "W osobnym oknie", + "chat.profile": "Twój profil", + "chat.profile.bio": "Opis", + "chat.profile.bio_ph": "Kilka słów o Tobie", + "chat.profile.id": "Identyfikator Twojego węzła", + "chat.profile.id_hint": "Tego potrzebuje ktoś inny, by zacząć z Tobą czat.", + "chat.profile.name": "Wyświetlana nazwa", + "chat.profile.name_hint": "To nazwa Twojego węzła, widoczna wszędzie, gdzie się pojawia, nie tylko na czacie.", + "chat.profile.name_ph": "Twoje imię", + "chat.profile.photo": "Zmień zdjęcie", + "chat.profile.save": "Zapisz profil", + "chat.profile.saved": "Profil zapisany", + "chat.react": "Zareaguj", + "chat.reply": "Odpowiedz", + "chat.reply_cancel": "Anuluj odpowiedź", + "chat.search.label": "Szukaj czatów i osób", + "chat.search.none": "Nikogo nie znaleziono.", + "chat.search.nopeople": "Nie znaleziono osób.", + "chat.search.people": "Osoby", + "chat.search.placeholder": "Szukaj czatów i osób…", + "chat.searching": "Szukam…", + "chat.set_name": "Ustaw swoje imię", + "chat.side": "Rozmowy", + "chat.skip": "Przejdź do rozmowy", + "chat.tab.chats": "Czaty", + "chat.tab.people": "Kontakty", + "chat.title": "Czat", + "chat.typing": "pisze…", + "chat.voice.cancel": "Odrzuć nagranie", + "chat.voice.denied": "Mikrofon jest niedostępny. Zezwól na niego w przeglądarce, aby nagrywać.", + "chat.voice.failed": "Nie udało się odtworzyć tej wiadomości głosowej.", + "chat.voice.message": "Wiadomość głosowa", + "chat.voice.play": "Odtwórz lub wstrzymaj", + "chat.voice.record": "Nagraj wiadomość głosową", + "chat.voice.recording": "Nagrywanie", + "chat.voice.seek": "Pozycja w wiadomości głosowej", + "chat.you": "Ty", "common.add": "Dodaj", "common.back": "Wstecz", + "common.back_to_console": "Wróć do konsoli", "common.cancel": "Anuluj", "common.close": "Zamknij", + "common.confirm": "Potwierdź", "common.copy": "Kopiuj", "common.delete": "Usuń", "common.edit": "Edytuj", @@ -16,6 +135,7 @@ "common.saved": "Zapisano", "common.search": "Szukaj", "common.send": "Wyślij", + "common.theme": "Zmień motyw", "common.unknown": "Nieznane", "config.saved": "Konfiguracja zapisana", "console.100_rows": "100 wierszy", @@ -558,6 +678,119 @@ "console.zoom": "Powiększenie", "console.zoom_in": "Powiększ", "console.zoom_out": "Pomniejsz", + "contacts.add": "Dodaj kontakt", + "contacts.blank.hint": "Wybierz kontakt albo dodaj nowy. Kontakt to nazwa, którą wybrałeś, i węzły, do których należy — czat i wszystkie inne aplikacje znajdują przez niego osoby.", + "contacts.blank.title": "Brak otwartego kontaktu", + "contacts.book.color": "Kolor", + "contacts.book.default": "Ustaw jako domyślną książkę", + "contacts.book.delete": "Usuń tę książkę", + "contacts.book.delete_body": "„{name}” i jej kontakty zostaną usunięte z tego węzła. Węzły, którym jest udostępniona, zachowają kopię jako własną książkę.", + "contacts.book.delete_title": "Usunąć tę książkę adresową?", + "contacts.book.leave": "Przestań obserwować", + "contacts.book.leave_body": "„{name}” zostanie usunięta z tego węzła. Właściciel ją zachowuje.", + "contacts.book.leave_title": "Przestać obserwować tę książkę adresową?", + "contacts.book.more": "Opcje książki adresowej", + "contacts.book.name": "Nazwa", + "contacts.book.new": "Nowa książka adresowa", + "contacts.book.private": "Tylko na tym węźle", + "contacts.book.rename": "Zmień nazwę lub kolor", + "contacts.book.shared_edit": "Udostępniona Tobie, możesz edytować", + "contacts.book.shared_out": "Udostępniona przez Ciebie", + "contacts.book.shared_read": "Udostępniona Tobie, tylko odczyt", + "contacts.chat": "Napisz", + "contacts.color.amber": "Bursztynowy", + "contacts.color.blue": "Niebieski", + "contacts.color.gray": "Szary", + "contacts.color.green": "Zielony", + "contacts.color.pink": "Różowy", + "contacts.color.purple": "Fioletowy", + "contacts.color.red": "Czerwony", + "contacts.color.teal": "Morski", + "contacts.ctx_local": "Kontakty działają na tym węźle — tamten nie udzielił mu dostępu do swoich aplikacji.", + "contacts.delete.body": "{name} zostanie usunięty z tej książki i z węzłów, którym jest udostępniona.", + "contacts.delete.title": "Usunąć ten kontakt?", + "contacts.empty.hint": "Dodaj kogoś po nazwie albo znajdź go w sieci.", + "contacts.empty.title": "Ta książka jest pusta", + "contacts.err.bad_node": "To nie jest identyfikator węzła.", + "contacts.err.empty": "Kontakt potrzebuje nazwy lub węzła.", + "contacts.err.full": "Ta książka jest pełna.", + "contacts.err.gone": "To zaproszenie już nie istnieje.", + "contacts.err.no_book": "Taka książka adresowa nie istnieje.", + "contacts.err.no_contact": "Taki kontakt nie istnieje.", + "contacts.err.not_owner": "Tylko węzeł, który utworzył książkę, może ją udostępnić.", + "contacts.err.permission": "Ta aplikacja nie otrzymała do tego uprawnienia.", + "contacts.err.read_only": "Ta książka jest udostępniona Tobie tylko do odczytu.", + "contacts.err.self": "To jest ten węzeł.", + "contacts.export": "Eksportuj jako vCard", + "contacts.favourite": "Ulubiony", + "contacts.import": "Importuj plik vCard", + "contacts.import.done": { + "one": "Zaimportowano {count} kontakt", + "few": "Zaimportowano {count} kontakty", + "many": "Zaimportowano {count} kontaktów", + "other": "Zaimportowano {count} kontaktu" + }, + "contacts.import.skipped": { + "one": "Pominięto {count} wpis", + "few": "Pominięto {count} wpisy", + "many": "Pominięto {count} wpisów", + "other": "Pominięto {count} wpisu" + }, + "contacts.login.hint": "Kontakty używają hasła konsoli tego węzła.", + "contacts.move": "Przenieś do innej książki", + "contacts.move.keep": "Zostaw tutaj", + "contacts.name": "Nazwa", + "contacts.name.placeholder": "Kto to?", + "contacts.new": "Nowy kontakt", + "contacts.no_node": "Brak węzła", + "contacts.node.add": "Dodaj węzeł", + "contacts.node.id": "Identyfikator węzła", + "contacts.node.known": "Węzeł nazywa go {name}", + "contacts.node.label": "Etykieta (telefon, praca…)", + "contacts.nodes": "Węzły", + "contacts.nodes.hint": "Osoba może używać kilku węzłów. Naprawdę identyfikuje ją identyfikator węzła; nazwa jest Twoja.", + "contacts.nomatch.hint": "Sprawdź pisownię albo użyj „Dodaj kontakt”, by przeszukać całą sieć.", + "contacts.nomatch.title": "Żaden kontakt nie pasuje", + "contacts.none.hint": "Utwórz ją plusem powyżej.", + "contacts.none.title": "Brak książek adresowych", + "contacts.note": "Notatka", + "contacts.note.placeholder": "Widzisz ją tylko Ty i czytelnicy tej książki", + "contacts.offer.accept": "Akceptuj", + "contacts.offer.decline": "Odrzuć", + "contacts.offer.from": "Od {who}", + "contacts.people.byid": "Albo dodaj po identyfikatorze węzła", + "contacts.people.label": "Szukaj w sieci po nazwie", + "contacts.people.none": "Nikt nie odpowiada na tę nazwę", + "contacts.people.none_hint": "Nazwy są szukane w całej sieci. Spróbuj innej pisowni albo wklej identyfikator węzła.", + "contacts.people.placeholder": "Nazwa, nawet jej część…", + "contacts.people.saved": "Zapisany", + "contacts.people.title": "Znajdź osoby", + "contacts.role.read": "Może czytać", + "contacts.role.write": "Może edytować", + "contacts.search.label": "Szukaj w tej książce", + "contacts.search.placeholder": "Szukaj po nazwie lub identyfikatorze węzła…", + "contacts.share.active": "Obserwuje tę książkę", + "contacts.share.add": "Zaproś węzeł", + "contacts.share.hint": "Inny węzeł dostanie tę książkę dopiero po akceptacji jego właściciela. Możesz ją odebrać w każdej chwili; jego kopia stanie się wtedy jego własną książką.", + "contacts.share.invite": "Zaproś", + "contacts.share.make_read": "Tylko odczyt", + "contacts.share.make_write": "Pozwól edytować", + "contacts.share.nobody": "Nie udostępniono nikomu.", + "contacts.share.node": "Nazwa lub identyfikator węzła", + "contacts.share.pending": "Czeka na odpowiedź", + "contacts.share.queued": "Ten węzeł nie odpowiedział. Zaproszenie zostało zachowane i zostanie wysłane ponownie.", + "contacts.share.revoke": "Odbierz", + "contacts.share.role": "Co może robić", + "contacts.share.sent": "Zaproszenie wysłane.", + "contacts.share.title": "Udostępnij tę książkę", + "contacts.skip": "Przejdź do kontaktu", + "contacts.title": "Kontakty", + "count.contact": { + "one": "{count} kontakt", + "few": "{count} kontakty", + "many": "{count} kontaktów", + "other": "{count} kontaktu" + }, "count.container": { "one": "{count} kontener", "few": "{count} kontenery", @@ -882,6 +1115,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "Wejdź", + "login.failed": "Logowanie nie powiodło się", + "login.password": "Hasło konsoli", + "login.title": "Logowanie", + "login.unreachable": "Konsola jest nieosiągalna", "node.1_hour": "1 godzina", "node.5_minutes": "5 minut", "node.6_hours": "6 godzin", @@ -899,6 +1137,8 @@ "node.state": "Stan", "node.this_page_reads_the_console_of_this": "Ta strona czyta konsolę tego węzła, więc potrzebuje hasła konsoli.", "node.tried": "Próbowano", + "palette.action": "Akcja", + "palette.goto": "Przejdź do", "pkg.downloaded": "Pobrano {path}.", "pkg.fetching_and_verifying": "Pobieranie i weryfikacja…", "pkg.fetching_the_package": "Pobieranie pakietu…", diff --git a/src/locales/ru.json b/src/locales/ru.json index 634943cc..6312b7eb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,8 +1,127 @@ { + "chat.attach": "Прикрепить файл", + "chat.back": "Назад к списку", + "chat.blank.hint": "Выберите собеседника слева или начните новый чат. Всё, что вы отправляете, шифруется сквозным шифрованием между двумя узлами.", + "chat.blank.start": "Начать чат", + "chat.blank.title": "Беседа не открыта", + "chat.contacts.add": "Добавить в контакты", + "chat.contacts.allow": "Разрешить и добавить", + "chat.contacts.ask.body": "Люди будут добавляться в адресную книгу по умолчанию.", + "chat.contacts.ask.title": "Чату нужно разрешение сохранять контакты", + "chat.contacts.manage": "Управление контактами", + "chat.contacts.refused": "Не удалось выдать это разрешение.", + "chat.contacts.saved": "{name} теперь в ваших контактах.", + "chat.contacts.unavailable": "Приложение «Контакты» не запущено на этом узле.", + "chat.ctx_local": "Чат работает на этом узле — тот узел не дал этому доступ к своим приложениям.", + "chat.delete.body": "На каждом узле, где она есть, она заменяется меткой удаления.", + "chat.delete.title": "Удалить это сообщение для всех?", + "chat.delete_conv": "Удалить беседу", + "chat.deleted": "сообщение удалено", + "chat.drop.body": "Беседа исчезнет с этого узла.", + "chat.drop.done": "Беседа удалена", + "chat.drop.rewrite": "Человек всё равно сможет написать вам снова. Ваши контакты не затрагиваются.", + "chat.drop.title": "Удалить эту беседу?", + "chat.edit.title": "Изменить сообщение", + "chat.edited": "изменено", + "chat.emoji": "Эмодзи", + "chat.empty.hint": "Начните её с помощью карандаша выше или из списка контактов.", + "chat.empty.title": "Бесед пока нет", + "chat.err.edit": "Изменение не сохранено.", + "chat.err.file_not_sent": "{name} не отправлен.", + "chat.err.group": "Группе нужны название и участник.", + "chat.err.name": "Это имя использовать нельзя.", + "chat.err.node_id": "Идентификатор узла — 40 шестнадцатеричных символов.", + "chat.err.not_sent": "Сообщение не отправлено — оно вернулось в поле ввода.", + "chat.err.unreadable": "Не удалось прочитать файл.", + "chat.file": "Файл", + "chat.group": "Группа", + "chat.group.left": "Вы покинули группу", + "chat.group.nobody": "Сначала добавьте контакты.", + "chat.jump": "Новые сообщения", + "chat.leave.body": "У остальных участников она останется.", + "chat.leave.confirm": "Покинуть группу", + "chat.leave.title": "Покинуть и удалить эту группу?", + "chat.login.hint": "Чат использует пароль консоли этого узла. Сообщения шифруются сквозным шифрованием; ретрансляторы передают их, не имея возможности прочитать.", + "chat.members": { + "one": "{count} участник", + "few": "{count} участника", + "many": "{count} участников", + "other": "{count} участника" + }, + "chat.message": "Сообщение", + "chat.messages": "Сообщения", + "chat.nc.by_name": "Поиск по имени…", + "chat.nc.create": "Создать группу", + "chat.nc.group": "Новая группа", + "chat.nc.group_name": "Название группы", + "chat.nc.members": "Участники", + "chat.nc.paste": "Или вставьте идентификатор узла", + "chat.nc.people": "Найти людей", + "chat.nc.start": "Начать", + "chat.new": "Новый чат", + "chat.node": "Узел", + "chat.nomatch.hint": "Попробуйте более короткое имя или вставьте идентификатор узла.", + "chat.nomatch.title": "Ничего не найдено", + "chat.palette": "Палитра команд", + "chat.palette_ph": "Выполнить действие…", + "chat.people.empty.hint": "Добавьте человека из беседы или управляйте адресными книгами в разделе «Контакты».", + "chat.people.empty.title": "Контактов пока нет", + "chat.people.locked.hint": "Разрешите чату использовать ваши контакты, чтобы видеть их здесь.", + "chat.people.locked.title": "Контакты для чата отключены", + "chat.perm.allow": "Разрешить", + "chat.perm.body": "Чат находит людей по именам, которые вы им дали, и может сохранять новых. Ничем ни с кем не делится.", + "chat.perm.no": "Не сейчас", + "chat.perm.read": "Только чтение", + "chat.perm.title": "Разрешить чату использовать контакты?", + "chat.photo": "Фото", + "chat.prefs": "Настройки", + "chat.prefs.details": "Открытие сведений об узле собеседника", + "chat.prefs.details_hint": "Нажатие на имя вверху беседы показывает, что этот узел знает об этой личности: соединение, адреса и что вы можете делать друг с другом.", + "chat.prefs.panel": "Рядом с беседой", + "chat.prefs.tab": "В новой вкладке", + "chat.prefs.window": "В отдельном окне", + "chat.profile": "Ваш профиль", + "chat.profile.bio": "О себе", + "chat.profile.bio_ph": "Несколько слов о себе", + "chat.profile.id": "Идентификатор вашего узла", + "chat.profile.id_hint": "Это нужно другому человеку, чтобы начать с вами чат.", + "chat.profile.name": "Отображаемое имя", + "chat.profile.name_hint": "Это имя вашего узла, оно видно везде, где он появляется, а не только в чате.", + "chat.profile.name_ph": "Ваше имя", + "chat.profile.photo": "Сменить фото", + "chat.profile.save": "Сохранить профиль", + "chat.profile.saved": "Профиль сохранён", + "chat.react": "Реакция", + "chat.reply": "Ответить", + "chat.reply_cancel": "Отменить ответ", + "chat.search.label": "Поиск чатов и людей", + "chat.search.none": "Никого не найдено.", + "chat.search.nopeople": "Люди не найдены.", + "chat.search.people": "Люди", + "chat.search.placeholder": "Поиск чатов и людей…", + "chat.searching": "Поиск…", + "chat.set_name": "Укажите имя", + "chat.side": "Беседы", + "chat.skip": "К беседе", + "chat.tab.chats": "Чаты", + "chat.tab.people": "Контакты", + "chat.title": "Чат", + "chat.typing": "печатает…", + "chat.voice.cancel": "Отменить запись", + "chat.voice.denied": "Микрофон недоступен. Разрешите его в браузере, чтобы записывать.", + "chat.voice.failed": "Не удалось воспроизвести голосовое сообщение.", + "chat.voice.message": "Голосовое сообщение", + "chat.voice.play": "Воспроизвести или пауза", + "chat.voice.record": "Записать голосовое сообщение", + "chat.voice.recording": "Идёт запись", + "chat.voice.seek": "Позиция в голосовом сообщении", + "chat.you": "Вы", "common.add": "Добавить", "common.back": "Назад", + "common.back_to_console": "Назад в консоль", "common.cancel": "Отмена", "common.close": "Закрыть", + "common.confirm": "Подтвердить", "common.copy": "Копировать", "common.delete": "Удалить", "common.edit": "Изменить", @@ -16,6 +135,7 @@ "common.saved": "Сохранено", "common.search": "Поиск", "common.send": "Отправить", + "common.theme": "Сменить тему", "common.unknown": "Неизвестно", "config.saved": "Конфигурация сохранена", "console.100_rows": "100 строк", @@ -558,6 +678,119 @@ "console.zoom": "Масштаб", "console.zoom_in": "Приблизить", "console.zoom_out": "Отдалить", + "contacts.add": "Добавить контакт", + "contacts.blank.hint": "Выберите контакт или добавьте новый. Контакт — это выбранное вами имя и узлы, которые ему соответствуют: чат и другие приложения находят людей через него.", + "contacts.blank.title": "Контакт не открыт", + "contacts.book.color": "Цвет", + "contacts.book.default": "Сделать книгой по умолчанию", + "contacts.book.delete": "Удалить эту книгу", + "contacts.book.delete_body": "«{name}» и её контакты удаляются с этого узла. Узлы, которым она доступна, сохраняют свою копию как собственную книгу.", + "contacts.book.delete_title": "Удалить эту адресную книгу?", + "contacts.book.leave": "Перестать следить", + "contacts.book.leave_body": "«{name}» удаляется с этого узла. У владельца она остаётся.", + "contacts.book.leave_title": "Перестать следить за этой адресной книгой?", + "contacts.book.more": "Параметры адресной книги", + "contacts.book.name": "Название", + "contacts.book.new": "Новая адресная книга", + "contacts.book.private": "Только на этом узле", + "contacts.book.rename": "Переименовать или перекрасить", + "contacts.book.shared_edit": "Доступна вам, можно редактировать", + "contacts.book.shared_out": "Вы поделились", + "contacts.book.shared_read": "Доступна вам, только чтение", + "contacts.chat": "Написать", + "contacts.color.amber": "Янтарный", + "contacts.color.blue": "Синий", + "contacts.color.gray": "Серый", + "contacts.color.green": "Зелёный", + "contacts.color.pink": "Розовый", + "contacts.color.purple": "Фиолетовый", + "contacts.color.red": "Красный", + "contacts.color.teal": "Бирюзовый", + "contacts.ctx_local": "Контакты работают на этом узле — тот узел не дал этому доступ к своим приложениям.", + "contacts.delete.body": "{name} удаляется из этой книги и с узлов, которым она доступна.", + "contacts.delete.title": "Удалить этот контакт?", + "contacts.empty.hint": "Добавьте человека по имени или найдите его в сети.", + "contacts.empty.title": "Эта книга пуста", + "contacts.err.bad_node": "Это не идентификатор узла.", + "contacts.err.empty": "Контакту нужны имя или узел.", + "contacts.err.full": "Эта книга заполнена.", + "contacts.err.gone": "Это приглашение больше не действует.", + "contacts.err.no_book": "Такой адресной книги нет.", + "contacts.err.no_contact": "Такого контакта нет.", + "contacts.err.not_owner": "Поделиться книгой может только создавший её узел.", + "contacts.err.permission": "У этого приложения нет на это разрешения.", + "contacts.err.read_only": "Эта книга доступна вам только для чтения.", + "contacts.err.self": "Это этот же узел.", + "contacts.export": "Экспорт в vCard", + "contacts.favourite": "Избранное", + "contacts.import": "Импорт файла vCard", + "contacts.import.done": { + "one": "Импортирован {count} контакт", + "few": "Импортировано {count} контакта", + "many": "Импортировано {count} контактов", + "other": "Импортировано {count} контакта" + }, + "contacts.import.skipped": { + "one": "Пропущена {count} запись", + "few": "Пропущено {count} записи", + "many": "Пропущено {count} записей", + "other": "Пропущено {count} записи" + }, + "contacts.login.hint": "Контакты используют пароль консоли этого узла.", + "contacts.move": "Переместить в другую книгу", + "contacts.move.keep": "Оставить здесь", + "contacts.name": "Имя", + "contacts.name.placeholder": "Кто это?", + "contacts.new": "Новый контакт", + "contacts.no_node": "Узла пока нет", + "contacts.node.add": "Добавить узел", + "contacts.node.id": "Идентификатор узла", + "contacts.node.known": "Узел называет его {name}", + "contacts.node.label": "Метка (телефон, работа…)", + "contacts.nodes": "Узлы", + "contacts.nodes.hint": "Человек может использовать несколько узлов. По-настоящему его определяет идентификатор узла; имя — ваше.", + "contacts.nomatch.hint": "Проверьте написание или используйте «Добавить контакт», чтобы искать по всей сети.", + "contacts.nomatch.title": "Совпадений нет", + "contacts.none.hint": "Создайте её кнопкой «плюс» выше.", + "contacts.none.title": "Нет адресных книг", + "contacts.note": "Заметка", + "contacts.note.placeholder": "Видна только вам и читателям этой книги", + "contacts.offer.accept": "Принять", + "contacts.offer.decline": "Отклонить", + "contacts.offer.from": "От {who}", + "contacts.people.byid": "Или добавить по идентификатору узла", + "contacts.people.label": "Поиск по имени в сети", + "contacts.people.none": "Никто не откликается на это имя", + "contacts.people.none_hint": "Имена ищутся по всей сети. Попробуйте другое написание или вставьте идентификатор узла.", + "contacts.people.placeholder": "Имя, даже его часть…", + "contacts.people.saved": "Сохранён", + "contacts.people.title": "Найти людей", + "contacts.role.read": "Только чтение", + "contacts.role.write": "Можно редактировать", + "contacts.search.label": "Поиск в этой книге", + "contacts.search.placeholder": "Поиск по имени или идентификатору узла…", + "contacts.share.active": "Следит за этой книгой", + "contacts.share.add": "Пригласить узел", + "contacts.share.hint": "Другой узел получит эту книгу, только когда его владелец примет приглашение. Вы можете отозвать её в любой момент; копия у него станет отдельной книгой.", + "contacts.share.invite": "Пригласить", + "contacts.share.make_read": "Только чтение", + "contacts.share.make_write": "Разрешить редактирование", + "contacts.share.nobody": "Ни с кем не поделились.", + "contacts.share.node": "Имя или идентификатор узла", + "contacts.share.pending": "Ожидает ответа", + "contacts.share.queued": "Узел не ответил. Приглашение сохранено и будет отправлено позже.", + "contacts.share.revoke": "Отозвать", + "contacts.share.role": "Что ему разрешено", + "contacts.share.sent": "Приглашение отправлено.", + "contacts.share.title": "Поделиться этой книгой", + "contacts.skip": "К контакту", + "contacts.title": "Контакты", + "count.contact": { + "one": "{count} контакт", + "few": "{count} контакта", + "many": "{count} контактов", + "other": "{count} контакта" + }, "count.container": { "one": "{count} контейнер", "few": "{count} контейнера", @@ -882,6 +1115,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "Войти", + "login.failed": "Не удалось войти", + "login.password": "Пароль консоли", + "login.title": "Вход", + "login.unreachable": "Консоль недоступна", "node.1_hour": "1 час", "node.5_minutes": "5 минут", "node.6_hours": "6 часов", @@ -899,6 +1137,8 @@ "node.state": "Состояние", "node.this_page_reads_the_console_of_this": "Эта страница читает консоль этого узла, поэтому нужен пароль консоли.", "node.tried": "Пробовали", + "palette.action": "Действие", + "palette.goto": "Перейти", "pkg.downloaded": "Скачано: {path}.", "pkg.fetching_and_verifying": "Получение и проверка…", "pkg.fetching_the_package": "Получение пакета…", diff --git a/src/locales/sk.json b/src/locales/sk.json index 4d9af16c..c08471b7 100644 --- a/src/locales/sk.json +++ b/src/locales/sk.json @@ -1,8 +1,127 @@ { + "chat.attach": "Priložiť súbor", + "chat.back": "Späť na zoznam", + "chat.blank.hint": "Vyber niekoho vľavo alebo začni nový chat. Všetko, čo pošleš, je šifrované end-to-end medzi dvoma uzlami.", + "chat.blank.start": "Začať chat", + "chat.blank.title": "Žiadna otvorená konverzácia", + "chat.contacts.add": "Pridať do kontaktov", + "chat.contacts.allow": "Povoliť a pridať", + "chat.contacts.ask.body": "Pridá ľudí do tvojho predvoleného adresára.", + "chat.contacts.ask.title": "Chat potrebuje povolenie ukladať kontakty", + "chat.contacts.manage": "Spravovať kontakty", + "chat.contacts.refused": "Toto povolenie sa nepodarilo udeliť.", + "chat.contacts.saved": "{name} je teraz v tvojich kontaktoch.", + "chat.contacts.unavailable": "Aplikácia Kontakty na tomto uzle nebeží.", + "chat.ctx_local": "Chat beží na tomto uzle — ten druhý mu nedal prístup k svojim aplikáciám.", + "chat.delete.body": "Na každom uzle, ktorý ju má, ju nahradí značka odstránenia.", + "chat.delete.title": "Odstrániť túto správu pre všetkých?", + "chat.delete_conv": "Odstrániť konverzáciu", + "chat.deleted": "odstránená správa", + "chat.drop.body": "Konverzácia zmizne z tohto uzla.", + "chat.drop.done": "Konverzácia odstránená", + "chat.drop.rewrite": "Môže ti napísať znova. Tvoje kontakty sa nemenia.", + "chat.drop.title": "Odstrániť túto konverzáciu?", + "chat.edit.title": "Upraviť túto správu", + "chat.edited": "upravené", + "chat.emoji": "Emoji", + "chat.empty.hint": "Začni ju ceruzkou vyššie alebo zo svojich kontaktov.", + "chat.empty.title": "Zatiaľ žiadna konverzácia", + "chat.err.edit": "Táto úprava sa neuložila.", + "chat.err.file_not_sent": "{name} sa neodoslal.", + "chat.err.group": "Skupina potrebuje názov a člena.", + "chat.err.name": "Toto meno sa nedá použiť.", + "chat.err.node_id": "Identifikátor uzla má 40 hexadecimálnych znakov.", + "chat.err.not_sent": "Táto správa sa neodoslala — vrátila sa do poľa.", + "chat.err.unreadable": "Súbor sa nepodarilo prečítať.", + "chat.file": "Súbor", + "chat.group": "Skupina", + "chat.group.left": "Skupina opustená", + "chat.group.nobody": "Najprv pridaj kontakty.", + "chat.jump": "Nové správy", + "chat.leave.body": "Ostatní členovia si ju ponechajú.", + "chat.leave.confirm": "Opustiť skupinu", + "chat.leave.title": "Opustiť a odstrániť túto skupinu?", + "chat.login.hint": "Chat používa heslo konzoly tohto uzla. Správy sú šifrované end-to-end; prenosové uzly ich len prenášajú a nemôžu ich čítať.", + "chat.members": { + "one": "{count} člen", + "few": "{count} členovia", + "many": "{count} člena", + "other": "{count} členov" + }, + "chat.message": "Správa", + "chat.messages": "Správy", + "chat.nc.by_name": "Hľadať podľa mena…", + "chat.nc.create": "Vytvoriť skupinu", + "chat.nc.group": "Nová skupina", + "chat.nc.group_name": "Názov skupiny", + "chat.nc.members": "Členovia", + "chat.nc.paste": "Alebo vlož identifikátor uzla", + "chat.nc.people": "Nájsť ľudí", + "chat.nc.start": "Začať", + "chat.new": "Nový chat", + "chat.node": "Uzol", + "chat.nomatch.hint": "Skús kratšie meno alebo vlož identifikátor uzla.", + "chat.nomatch.title": "Nič nezodpovedá", + "chat.palette": "Paleta príkazov", + "chat.palette_ph": "Spustiť akciu…", + "chat.people.empty.hint": "Pridaj niekoho z konverzácie alebo spravuj adresáre v Kontaktoch.", + "chat.people.empty.title": "Zatiaľ žiadne kontakty", + "chat.people.locked.hint": "Povoľ chatu používať tvoje kontakty, aby sa zobrazili tu.", + "chat.people.locked.title": "Kontakty sú pre chat vypnuté", + "chat.perm.allow": "Povoliť", + "chat.perm.body": "Chat nájde ľudí podľa mien, ktoré si im dal, a môže ukladať nových. Nič sa s nikým nezdieľa.", + "chat.perm.no": "Teraz nie", + "chat.perm.read": "Iba čítanie", + "chat.perm.title": "Povoliť chatu používať kontakty?", + "chat.photo": "Fotka", + "chat.prefs": "Predvoľby", + "chat.prefs.details": "Otváranie podrobností o uzle osoby", + "chat.prefs.details_hint": "Ťuknutie na meno hore v konverzácii ukáže, čo tento uzol o tej identite vie: spojenie, adresy a čo si môžete navzájom robiť.", + "chat.prefs.panel": "Vedľa konverzácie", + "chat.prefs.tab": "V novej karte", + "chat.prefs.window": "V samostatnom okne", + "chat.profile": "Tvoj profil", + "chat.profile.bio": "O mne", + "chat.profile.bio_ph": "Pár slov o tebe", + "chat.profile.id": "Identifikátor tvojho uzla", + "chat.profile.id_hint": "Toto potrebuje niekto iný, aby s tebou začal chat.", + "chat.profile.name": "Zobrazované meno", + "chat.profile.name_hint": "Toto je meno tvojho uzla, zobrazené všade, kde sa objaví, nielen v chate.", + "chat.profile.name_ph": "Tvoje meno", + "chat.profile.photo": "Zmeniť fotku", + "chat.profile.save": "Uložiť profil", + "chat.profile.saved": "Profil uložený", + "chat.react": "Reagovať", + "chat.reply": "Odpovedať", + "chat.reply_cancel": "Zrušiť odpoveď", + "chat.search.label": "Hľadať chaty a ľudí", + "chat.search.none": "Nikto sa nenašiel.", + "chat.search.nopeople": "Nenašli sa žiadni ľudia.", + "chat.search.people": "Ľudia", + "chat.search.placeholder": "Hľadať chaty a ľudí…", + "chat.searching": "Hľadám…", + "chat.set_name": "Nastav si meno", + "chat.side": "Konverzácie", + "chat.skip": "Prejsť na konverzáciu", + "chat.tab.chats": "Chaty", + "chat.tab.people": "Kontakty", + "chat.title": "Chat", + "chat.typing": "píše…", + "chat.voice.cancel": "Zahodiť nahrávku", + "chat.voice.denied": "Mikrofón nie je dostupný. Povoľ ho v prehliadači, aby si mohol nahrávať.", + "chat.voice.failed": "Túto hlasovú správu sa nepodarilo prehrať.", + "chat.voice.message": "Hlasová správa", + "chat.voice.play": "Prehrať alebo pozastaviť", + "chat.voice.record": "Nahrať hlasovú správu", + "chat.voice.recording": "Nahrávanie", + "chat.voice.seek": "Pozícia v hlasovej správe", + "chat.you": "Ty", "common.add": "Pridať", "common.back": "Späť", + "common.back_to_console": "Späť do konzoly", "common.cancel": "Zrušiť", "common.close": "Zavrieť", + "common.confirm": "Potvrdiť", "common.copy": "Kopírovať", "common.delete": "Odstrániť", "common.edit": "Upraviť", @@ -16,6 +135,7 @@ "common.saved": "Uložené", "common.search": "Hľadať", "common.send": "Odoslať", + "common.theme": "Zmeniť motív", "common.unknown": "Neznáme", "config.saved": "Konfigurácia uložená", "console.100_rows": "100 riadkov", @@ -558,6 +678,119 @@ "console.zoom": "Priblíženie", "console.zoom_in": "Priblížiť", "console.zoom_out": "Oddialiť", + "contacts.add": "Pridať kontakt", + "contacts.blank.hint": "Vyber kontakt alebo pridaj nový. Kontakt je meno, ktoré si zvolil, a uzly, ktoré mu zodpovedajú — chat a všetky ostatné aplikácie cez neho nachádzajú ľudí.", + "contacts.blank.title": "Žiadny otvorený kontakt", + "contacts.book.color": "Farba", + "contacts.book.default": "Nastaviť ako predvolený adresár", + "contacts.book.delete": "Odstrániť tento adresár", + "contacts.book.delete_body": "„{name}“ a jeho kontakty sa odstránia z tohto uzla. Uzly, s ktorými je zdieľaný, si ponechajú kópiu ako vlastný adresár.", + "contacts.book.delete_title": "Odstrániť tento adresár?", + "contacts.book.leave": "Prestať sledovať", + "contacts.book.leave_body": "„{name}“ sa odstráni z tohto uzla. Vlastník si ho ponechá.", + "contacts.book.leave_title": "Prestať sledovať tento adresár?", + "contacts.book.more": "Možnosti adresára", + "contacts.book.name": "Názov", + "contacts.book.new": "Nový adresár", + "contacts.book.private": "Iba na tomto uzle", + "contacts.book.rename": "Premenovať alebo prefarbiť", + "contacts.book.shared_edit": "Zdieľaný s tebou, môžeš upravovať", + "contacts.book.shared_out": "Zdieľaný tebou", + "contacts.book.shared_read": "Zdieľaný s tebou, iba čítanie", + "contacts.chat": "Napísať", + "contacts.color.amber": "Jantárová", + "contacts.color.blue": "Modrá", + "contacts.color.gray": "Sivá", + "contacts.color.green": "Zelená", + "contacts.color.pink": "Ružová", + "contacts.color.purple": "Fialová", + "contacts.color.red": "Červená", + "contacts.color.teal": "Tyrkysová", + "contacts.ctx_local": "Kontakty bežia na tomto uzle — ten druhý mu nedal prístup k svojim aplikáciám.", + "contacts.delete.body": "{name} sa odstráni z tohto adresára aj z uzlov, s ktorými je zdieľaný.", + "contacts.delete.title": "Odstrániť tento kontakt?", + "contacts.empty.hint": "Pridaj niekoho podľa mena alebo ho nájdi v sieti.", + "contacts.empty.title": "Tento adresár je prázdny", + "contacts.err.bad_node": "Toto nie je identifikátor uzla.", + "contacts.err.empty": "Kontakt potrebuje meno alebo uzol.", + "contacts.err.full": "Tento adresár je plný.", + "contacts.err.gone": "Táto pozvánka už neexistuje.", + "contacts.err.no_book": "Tento adresár neexistuje.", + "contacts.err.no_contact": "Tento kontakt neexistuje.", + "contacts.err.not_owner": "Adresár môže zdieľať iba uzol, ktorý ho vytvoril.", + "contacts.err.permission": "Táto aplikácia na to nedostala povolenie.", + "contacts.err.read_only": "Tento adresár je s tebou zdieľaný iba na čítanie.", + "contacts.err.self": "To je tento uzol.", + "contacts.export": "Exportovať ako vCard", + "contacts.favourite": "Obľúbený", + "contacts.import": "Importovať súbor vCard", + "contacts.import.done": { + "one": "Importovaný {count} kontakt", + "few": "Importované {count} kontakty", + "many": "Importovaného {count} kontaktu", + "other": "Importovaných {count} kontaktov" + }, + "contacts.import.skipped": { + "one": "Preskočený {count} záznam", + "few": "Preskočené {count} záznamy", + "many": "Preskočeného {count} záznamu", + "other": "Preskočených {count} záznamov" + }, + "contacts.login.hint": "Kontakty používajú heslo konzoly tohto uzla.", + "contacts.move": "Presunúť do iného adresára", + "contacts.move.keep": "Nechať tu", + "contacts.name": "Meno", + "contacts.name.placeholder": "Kto je to?", + "contacts.new": "Nový kontakt", + "contacts.no_node": "Zatiaľ žiadny uzol", + "contacts.node.add": "Pridať uzol", + "contacts.node.id": "Identifikátor uzla", + "contacts.node.known": "Uzol ho nazýva {name}", + "contacts.node.label": "Označenie (telefón, práca…)", + "contacts.nodes": "Uzly", + "contacts.nodes.hint": "Osoba môže používať viac uzlov. Skutočne ju identifikuje identifikátor uzla; meno je tvoje.", + "contacts.nomatch.hint": "Skontroluj pravopis alebo použi „Pridať kontakt“ na hľadanie v celej sieti.", + "contacts.nomatch.title": "Žiadny kontakt nezodpovedá", + "contacts.none.hint": "Vytvor ho plusom vyššie.", + "contacts.none.title": "Žiadny adresár", + "contacts.note": "Poznámka", + "contacts.note.placeholder": "Vidíš ju iba ty a čitatelia tohto adresára", + "contacts.offer.accept": "Prijať", + "contacts.offer.decline": "Odmietnuť", + "contacts.offer.from": "Od {who}", + "contacts.people.byid": "Alebo pridať podľa identifikátora uzla", + "contacts.people.label": "Hľadať v sieti podľa mena", + "contacts.people.none": "Na toto meno nikto nereaguje", + "contacts.people.none_hint": "Mená sa hľadajú v celej sieti. Skús iný pravopis alebo vlož identifikátor uzla.", + "contacts.people.placeholder": "Meno, aj jeho časť…", + "contacts.people.saved": "Uložený", + "contacts.people.title": "Nájsť ľudí", + "contacts.role.read": "Môže čítať", + "contacts.role.write": "Môže upravovať", + "contacts.search.label": "Hľadať v tomto adresári", + "contacts.search.placeholder": "Hľadať podľa mena alebo identifikátora uzla…", + "contacts.share.active": "Sleduje tento adresár", + "contacts.share.add": "Pozvať uzol", + "contacts.share.hint": "Iný uzol dostane tento adresár až po tom, čo jeho vlastník prijme. Môžeš ho kedykoľvek vziať späť; jeho kópia sa potom stane jeho vlastným adresárom.", + "contacts.share.invite": "Pozvať", + "contacts.share.make_read": "Iba čítanie", + "contacts.share.make_write": "Povoliť úpravy", + "contacts.share.nobody": "S nikým nezdieľané.", + "contacts.share.node": "Meno alebo identifikátor uzla", + "contacts.share.pending": "Čaká na odpoveď", + "contacts.share.queued": "Tento uzol neodpovedal. Pozvánka sa zachová a odošle neskôr.", + "contacts.share.revoke": "Vziať späť", + "contacts.share.role": "Čo môže robiť", + "contacts.share.sent": "Pozvánka odoslaná.", + "contacts.share.title": "Zdieľať tento adresár", + "contacts.skip": "Prejsť na kontakt", + "contacts.title": "Kontakty", + "count.contact": { + "one": "{count} kontakt", + "few": "{count} kontakty", + "many": "{count} kontaktu", + "other": "{count} kontaktov" + }, "count.container": { "one": "{count} kontajner", "few": "{count} kontajnery", @@ -882,6 +1115,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "Vstúpiť", + "login.failed": "Prihlásenie zlyhalo", + "login.password": "Heslo konzoly", + "login.title": "Prihlásenie", + "login.unreachable": "Konzola je nedostupná", "node.1_hour": "1 hodina", "node.5_minutes": "5 minút", "node.6_hours": "6 hodín", @@ -899,6 +1137,8 @@ "node.state": "Stav", "node.this_page_reads_the_console_of_this": "Táto stránka číta konzolu tohto uzla, preto vyžaduje heslo konzoly.", "node.tried": "Vyskúšané", + "palette.action": "Akcia", + "palette.goto": "Prejsť na", "pkg.downloaded": "Stiahnuté {path}.", "pkg.fetching_and_verifying": "Sťahovanie a overovanie…", "pkg.fetching_the_package": "Sťahovanie balíka…", diff --git a/src/locales/zh.json b/src/locales/zh.json index f55a9172..bc2ff3c7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,8 +1,124 @@ { + "chat.attach": "添加附件", + "chat.back": "返回列表", + "chat.blank.hint": "在左侧选择联系人,或新建聊天。你发送的一切都在两个节点之间端到端加密。", + "chat.blank.start": "开始聊天", + "chat.blank.title": "未打开对话", + "chat.contacts.add": "添加到联系人", + "chat.contacts.allow": "允许并添加", + "chat.contacts.ask.body": "联系人将添加到你的默认通讯录。", + "chat.contacts.ask.title": "聊天需要保存联系人的权限", + "chat.contacts.manage": "管理联系人", + "chat.contacts.refused": "无法授予该权限。", + "chat.contacts.saved": "{name} 已添加到你的联系人。", + "chat.contacts.unavailable": "此节点上未运行“通讯录”应用。", + "chat.ctx_local": "聊天在此节点上运行——对方节点没有授予它应用权限。", + "chat.delete.body": "每个持有该消息的节点上都会以删除标记替换它。", + "chat.delete.title": "要为所有人删除这条消息吗?", + "chat.delete_conv": "删除对话", + "chat.deleted": "消息已删除", + "chat.drop.body": "该对话将从此节点消失。", + "chat.drop.done": "对话已移除", + "chat.drop.rewrite": "对方仍可再次给你发消息。你的联系人不受影响。", + "chat.drop.title": "要移除此对话吗?", + "chat.edit.title": "编辑此消息", + "chat.edited": "已编辑", + "chat.emoji": "表情", + "chat.empty.hint": "点击上方的铅笔图标,或从联系人中开始。", + "chat.empty.title": "还没有对话", + "chat.err.edit": "该编辑未保存。", + "chat.err.file_not_sent": "{name} 未发送。", + "chat.err.group": "群组需要名称和至少一位成员。", + "chat.err.name": "无法使用该名字。", + "chat.err.node_id": "节点 ID 由 40 个十六进制字符组成。", + "chat.err.not_sent": "消息未发送——已放回输入框。", + "chat.err.unreadable": "无法读取该文件。", + "chat.file": "文件", + "chat.group": "群组", + "chat.group.left": "已退出群组", + "chat.group.nobody": "请先添加联系人。", + "chat.jump": "新消息", + "chat.leave.body": "其他成员会保留他们的副本。", + "chat.leave.confirm": "退出群组", + "chat.leave.title": "要退出并删除此群组吗?", + "chat.login.hint": "聊天使用此节点的控制台密码。消息经端到端加密;中继只负责传递,无法读取内容。", + "chat.members": { + "other": "{count} 位成员" + }, + "chat.message": "消息", + "chat.messages": "消息", + "chat.nc.by_name": "按名称搜索…", + "chat.nc.create": "创建群组", + "chat.nc.group": "新建群组", + "chat.nc.group_name": "群组名称", + "chat.nc.members": "成员", + "chat.nc.paste": "或粘贴节点 ID", + "chat.nc.people": "查找联系人", + "chat.nc.start": "开始", + "chat.new": "新建聊天", + "chat.node": "节点", + "chat.nomatch.hint": "试试更短的名字,或粘贴节点 ID。", + "chat.nomatch.title": "没有匹配项", + "chat.palette": "命令面板", + "chat.palette_ph": "执行操作…", + "chat.people.empty.hint": "从对话中添加联系人,或在“通讯录”中管理你的通讯录。", + "chat.people.empty.title": "还没有联系人", + "chat.people.locked.hint": "允许聊天使用你的联系人后,即可在此查看。", + "chat.people.locked.title": "聊天未获得联系人权限", + "chat.perm.allow": "允许", + "chat.perm.body": "聊天会按你起的名字查找联系人,并可保存新的联系人。不会与任何人共享。", + "chat.perm.no": "暂不", + "chat.perm.read": "仅读取", + "chat.perm.title": "允许聊天使用你的联系人吗?", + "chat.photo": "照片", + "chat.prefs": "偏好设置", + "chat.prefs.details": "打开对方的节点详情", + "chat.prefs.details_hint": "点按对话顶部的名称,可查看此节点所知的该身份信息——连接、地址,以及双方可对彼此进行的操作。", + "chat.prefs.panel": "在对话旁边", + "chat.prefs.tab": "在新标签页中", + "chat.prefs.window": "在单独的窗口中", + "chat.profile": "你的资料", + "chat.profile.bio": "简介", + "chat.profile.bio_ph": "简单介绍一下你自己", + "chat.profile.id": "你的节点 ID", + "chat.profile.id_hint": "他人需要它才能与你开始聊天。", + "chat.profile.name": "显示名称", + "chat.profile.name_hint": "这是你节点的名称,会显示在所有出现它的地方,而不只是聊天。", + "chat.profile.name_ph": "你的名字", + "chat.profile.photo": "更换照片", + "chat.profile.save": "保存资料", + "chat.profile.saved": "资料已保存", + "chat.react": "回应", + "chat.reply": "回复", + "chat.reply_cancel": "取消回复", + "chat.search.label": "搜索聊天和联系人", + "chat.search.none": "没有找到任何人。", + "chat.search.nopeople": "没有找到联系人。", + "chat.search.people": "联系人", + "chat.search.placeholder": "搜索聊天和联系人…", + "chat.searching": "搜索中…", + "chat.set_name": "设置你的名字", + "chat.side": "对话", + "chat.skip": "跳到对话", + "chat.tab.chats": "聊天", + "chat.tab.people": "联系人", + "chat.title": "聊天", + "chat.typing": "正在输入…", + "chat.voice.cancel": "放弃录音", + "chat.voice.denied": "无法使用麦克风。请在浏览器中允许后再录音。", + "chat.voice.failed": "无法播放该语音消息。", + "chat.voice.message": "语音消息", + "chat.voice.play": "播放或暂停", + "chat.voice.record": "录制语音消息", + "chat.voice.recording": "录音中", + "chat.voice.seek": "语音消息进度", + "chat.you": "你", "common.add": "添加", "common.back": "返回", + "common.back_to_console": "返回控制台", "common.cancel": "取消", "common.close": "关闭", + "common.confirm": "确认", "common.copy": "复制", "common.delete": "删除", "common.edit": "编辑", @@ -16,6 +132,7 @@ "common.saved": "已保存", "common.search": "搜索", "common.send": "发送", + "common.theme": "切换主题", "common.unknown": "未知", "config.saved": "配置已保存", "console.100_rows": "100 行", @@ -555,6 +672,110 @@ "console.zoom": "缩放", "console.zoom_in": "放大", "console.zoom_out": "缩小", + "contacts.add": "添加联系人", + "contacts.blank.hint": "选择一位联系人,或添加新的。联系人是你起的名字及其对应的节点——聊天和其他所有应用都通过它找到人。", + "contacts.blank.title": "未打开联系人", + "contacts.book.color": "颜色", + "contacts.book.default": "设为默认通讯录", + "contacts.book.delete": "删除此通讯录", + "contacts.book.delete_body": "“{name}”及其联系人将从此节点移除。与之共享的节点会把副本保留为各自的通讯录。", + "contacts.book.delete_title": "要删除此通讯录吗?", + "contacts.book.leave": "取消关注", + "contacts.book.leave_body": "“{name}”将从此节点移除。其所有者仍保留它。", + "contacts.book.leave_title": "要取消关注此通讯录吗?", + "contacts.book.more": "通讯录选项", + "contacts.book.name": "名称", + "contacts.book.new": "新建通讯录", + "contacts.book.private": "仅在此节点", + "contacts.book.rename": "重命名或更改颜色", + "contacts.book.shared_edit": "与你共享,可编辑", + "contacts.book.shared_out": "由你共享", + "contacts.book.shared_read": "与你共享,只读", + "contacts.chat": "发消息", + "contacts.color.amber": "琥珀色", + "contacts.color.blue": "蓝色", + "contacts.color.gray": "灰色", + "contacts.color.green": "绿色", + "contacts.color.pink": "粉色", + "contacts.color.purple": "紫色", + "contacts.color.red": "红色", + "contacts.color.teal": "青色", + "contacts.ctx_local": "通讯录在此节点上运行——对方节点没有授予它应用权限。", + "contacts.delete.body": "{name} 将从此通讯录以及与之共享的节点中移除。", + "contacts.delete.title": "要删除此联系人吗?", + "contacts.empty.hint": "按名称添加联系人,或在网络中查找。", + "contacts.empty.title": "此通讯录为空", + "contacts.err.bad_node": "这不是有效的节点 ID。", + "contacts.err.empty": "联系人需要名称或节点。", + "contacts.err.full": "此通讯录已满。", + "contacts.err.gone": "该邀请已失效。", + "contacts.err.no_book": "该通讯录不存在。", + "contacts.err.no_contact": "该联系人不存在。", + "contacts.err.not_owner": "只有创建通讯录的节点才能共享它。", + "contacts.err.permission": "此应用没有获得相应权限。", + "contacts.err.read_only": "此通讯录仅供你读取。", + "contacts.err.self": "这就是此节点。", + "contacts.export": "导出为 vCard", + "contacts.favourite": "收藏", + "contacts.import": "导入 vCard 文件", + "contacts.import.done": { + "other": "已导入 {count} 位联系人" + }, + "contacts.import.skipped": { + "other": "已跳过 {count} 条" + }, + "contacts.login.hint": "通讯录使用此节点的控制台密码。", + "contacts.move": "移到其他通讯录", + "contacts.move.keep": "保留在此", + "contacts.name": "姓名", + "contacts.name.placeholder": "这是谁?", + "contacts.new": "新联系人", + "contacts.no_node": "尚无节点", + "contacts.node.add": "添加节点", + "contacts.node.id": "节点 ID", + "contacts.node.known": "节点称其为 {name}", + "contacts.node.label": "标签(手机、工作…)", + "contacts.nodes": "节点", + "contacts.nodes.hint": "一个人可以使用多个节点。真正标识对方的是节点 ID;名称由你决定。", + "contacts.nomatch.hint": "请检查拼写,或使用“添加联系人”搜索整个网络。", + "contacts.nomatch.title": "没有匹配的联系人", + "contacts.none.hint": "点击上方的加号创建。", + "contacts.none.title": "没有通讯录", + "contacts.note": "备注", + "contacts.note.placeholder": "只有你和此通讯录的读者可见", + "contacts.offer.accept": "接受", + "contacts.offer.decline": "拒绝", + "contacts.offer.from": "来自 {who}", + "contacts.people.byid": "或通过节点 ID 添加", + "contacts.people.label": "按名称在网络中搜索", + "contacts.people.none": "没有人使用这个名字", + "contacts.people.none_hint": "会在整个网络中查找名称。请换一种拼写,或粘贴节点 ID。", + "contacts.people.placeholder": "一个名字,甚至一部分…", + "contacts.people.saved": "已保存", + "contacts.people.title": "查找联系人", + "contacts.role.read": "可读取", + "contacts.role.write": "可编辑", + "contacts.search.label": "在此通讯录中搜索", + "contacts.search.placeholder": "按名称或节点 ID 搜索…", + "contacts.share.active": "正在关注此通讯录", + "contacts.share.add": "邀请节点", + "contacts.share.hint": "只有对方节点的所有者接受后,该节点才会获得此通讯录。你可以随时收回;其副本此后成为它自己的通讯录。", + "contacts.share.invite": "邀请", + "contacts.share.make_read": "改为只读", + "contacts.share.make_write": "允许编辑", + "contacts.share.nobody": "尚未与任何人共享。", + "contacts.share.node": "名称或节点 ID", + "contacts.share.pending": "等待回复", + "contacts.share.queued": "该节点没有响应。邀请已保留,稍后会重新发送。", + "contacts.share.revoke": "收回", + "contacts.share.role": "其权限", + "contacts.share.sent": "邀请已发送。", + "contacts.share.title": "共享此通讯录", + "contacts.skip": "跳到联系人", + "contacts.title": "通讯录", + "count.contact": { + "other": "{count} 位联系人" + }, "count.container": { "other": "{count} 个容器" }, @@ -825,6 +1046,11 @@ "language.name.ru": "Русский", "language.name.sk": "Slovenčina", "language.name.zh": "中文", + "login.enter": "进入", + "login.failed": "登录失败", + "login.password": "控制台密码", + "login.title": "登录", + "login.unreachable": "无法连接到控制台", "node.1_hour": "1 小时", "node.5_minutes": "5 分钟", "node.6_hours": "6 小时", @@ -842,6 +1068,8 @@ "node.state": "状态", "node.this_page_reads_the_console_of_this": "此页面读取本节点的控制台,因此需要控制台密码。", "node.tried": "已尝试", + "palette.action": "操作", + "palette.goto": "前往", "pkg.downloaded": "已下载 {path}。", "pkg.fetching_and_verifying": "正在获取并验证…", "pkg.fetching_the_package": "正在获取软件包…", diff --git a/src/version.py b/src/version.py index 5b8914c6..8a1bfe74 100644 --- a/src/version.py +++ b/src/version.py @@ -10,7 +10,7 @@ import re -__version__ = "0.4.118" +__version__ = "0.4.119" # Releases are tagged ``vX.Y.Z`` (see .github/workflows/release.yml). Anything # after the numbers (``-rc1``, ``+build``) is kept but only used to break ties diff --git a/src/webassets/__init__.py b/src/webassets/__init__.py index 52a2aef9..ab3550c6 100644 --- a/src/webassets/__init__.py +++ b/src/webassets/__init__.py @@ -16,7 +16,7 @@ target rather than a second set of paths. It ships with every page, right after ``ui.JS``, because a page that could not reach the plane could not read the node at all. -* :mod:`.console`, :mod:`.chat`, :mod:`.fleet` and :mod:`.nodeview` hold one +* :mod:`.console`, :mod:`.chat`, :mod:`.contacts`, :mod:`.fleet` and :mod:`.nodeview` hold one page each: markup, the page's own JS, and only the CSS that genuinely belongs to that page. * :mod:`.terminal` is the other shared piece: the emulator, the session driver @@ -41,6 +41,7 @@ from . import ui from . import channel from .chat import CHAT_HTML, CHAT_PAGE_CSS, CHAT_PAGE_JS +from .contacts import CONTACTS_HTML, CONTACTS_PAGE_CSS, CONTACTS_PAGE_JS from .console import INDEX_HTML, CONSOLE_PAGE_CSS, CONSOLE_PAGE_JS from .fleet import FLEET_HTML, FLEET_PAGE_CSS, FLEET_PAGE_JS from .nodeview import (NODE_PAGE_CSS_FULL as _NODE_PAGE_CSS, @@ -59,6 +60,9 @@ CHAT_CSS = ui.CSS + packages.CSS + nodeview.CSS + CHAT_PAGE_CSS CHAT_JS = ui.JS + channel.JS + packages.JS + nodeview.JS + CHAT_PAGE_JS +CONTACTS_CSS = ui.CSS + CONTACTS_PAGE_CSS +CONTACTS_JS = ui.JS + channel.JS + CONTACTS_PAGE_JS + FLEET_CSS = ui.CSS + packages.CSS + nodeview.CSS + terminal.CSS + FLEET_PAGE_CSS FLEET_JS = (ui.JS + channel.JS + packages.JS + nodeview.JS + terminal.JS + FLEET_PAGE_JS) @@ -77,6 +81,7 @@ __all__ = ["INDEX_HTML", "STYLE_CSS", "APP_JS", "CHAT_HTML", "CHAT_CSS", "CHAT_JS", + "CONTACTS_HTML", "CONTACTS_CSS", "CONTACTS_JS", "FLEET_HTML", "FLEET_CSS", "FLEET_JS", "TERM_HTML", "TERM_CSS", "TERM_JS", "NODE_HTML", "NODE_CSS", "NODE_JS", diff --git a/src/webassets/chat.py b/src/webassets/chat.py index d08ccd15..8100e52f 100644 --- a/src/webassets/chat.py +++ b/src/webassets/chat.py @@ -1,20 +1,25 @@ """ Chat sub-page (/chat). -A full chat client — identity, contact directory, 1:1 and group conversations, -files, replies and reactions — served by the console and backed by the -ChatBridge. Same session, same strict CSP: no inline script, no external -resource. +A full chat client — identity, 1:1 and group conversations, the people chat may +know, files, voice messages, replies and reactions — served by the console and +backed by the ChatBridge. Same session, same strict CSP: no inline script, no +external resource. Every sentence is read from the catalogue (``t()`` and +``data-i18n``), so the page speaks the language the person chose. Three panes, one grid: the conversation list, the thread, and the node card of whoever you are talking to. Which of them is on screen is a single attribute (``data-view``) rather than a set of classes that can contradict each other, so -"what am I looking at" has exactly one answer at any width. +"what am I looking at" has exactly one answer at any width. The stylesheet picks +one pane (a phone) or the columns (a desktop) from the width, and the pointer +decides how large a target is: the page never asks which device it is on. + +Chat keeps no address book of its own. The people in the second tab are the +contacts app's, read through the permission the person gave chat. Everything here is prefixed ``ch-``. The design system in :mod:`.ui` owns the -generic names, and a page that redefines one of them (this page used to redefine -``.msg``) silently changes a component for itself alone — which is the drift the -split exists to prevent. +generic names, and a page that redefines one of them silently changes a +component for itself alone — which is the drift the split exists to prevent. """ from . import ui @@ -34,64 +39,81 @@ + data-ctx-local="" data-ctx-local-key="chat.ctx_local"> - + """ + ui.CTX_BAR + """ - +
- +
@@ -535,6 +569,39 @@ @media (max-width:1024px) and (min-width:861px){ :root{--page-side-w:270px} } + +/* ── the side's two lists, the permission card ─────────────────────────── */ +.ch-tabs{margin:0 var(--s-3) var(--s-2)} +.ch-perm{margin:0 var(--s-3) var(--s-2);padding:var(--s-3);border:1px solid var(--accent-line); + border-radius:var(--r-md);background:var(--accent-soft);display:flex;flex-direction:column;gap:var(--s-2)} +.ch-perm[hidden]{display:none} +.ch-perm-t{font-weight:650} +.ch-perm-b{display:flex;flex-wrap:wrap;gap:var(--s-2)} +.ch-list[hidden]{display:none} +.ch-side-foot{display:flex;flex-direction:column;gap:var(--s-2)} + +/* ── voice ──────────────────────────────────────────────────────────────── */ +.ch-voice{display:flex;align-items:center;gap:var(--s-2);min-width:min(220px,60vw)} +.ch-vplay{flex:none} +.ch-vbar{flex:1 1 auto;min-width:0;accent-color:var(--accent)} +.ch-vtime{flex:none;font-size:var(--fs-xs);color:var(--text-muted)} +/* While recording the composer shows the recorder and nothing else, so a tap + cannot send half a sentence typed beside it. */ +.ch-rec{display:none;flex:1 1 auto;align-items:center;gap:var(--s-2);min-width:0} +.ch-composer.recording>:not(.ch-rec){display:none} +.ch-composer.recording>.ch-rec{display:flex} +.ch-rec-dot{width:10px;height:10px;border-radius:50%;background:var(--danger,#cf222e);flex:none} +@media (prefers-reduced-motion:no-preference){ + .ch-rec-dot{animation:ch-pulse 1.2s ease-in-out infinite} +} +@keyframes ch-pulse{50%{opacity:.25}} + +/* A touch screen gets bigger targets and no hover-only affordance; the + decision is the pointer's, not a user-agent string's. */ +@media (pointer:coarse){ + .ch-row{padding:var(--s-3) var(--s-2)} + .ch-head-act,.ch-composer .icon{min-width:44px;min-height:44px} +} """ CHAT_PAGE_JS = r""" @@ -545,7 +612,10 @@ // keyed — a row is created once, updated in place, and removed when it goes. let VER = 0, sel = null, timer = null; -let ST = {me:null, pseudo:"", bio:"", has_avatar:false, contacts:[], known:[], groups:[]}; +let ST = {me:null, pseudo:"", bio:"", has_avatar:false, contacts:[], known:[], groups:[], + contacts_access:"unavailable"}; +let PANE = "chats"; // which list the side shows: conversations or contacts +const OPENED = new Set(); // chats started this session that have no message yet let UNREAD = {}, TYPING = {}, replyTo = null, ncSel = {}; const MSGS = {}; // conv -> {id -> record} const REACTS = ["👍","❤️","😂","😮","😢","🔥","🎉","👏"]; @@ -575,13 +645,13 @@ return ST.contacts.find((c) => c.id === id) || ST.known.find((c) => c.id === id) || null; } function personName(id){ - if(id === ST.me) return ST.pseudo || "You"; - const r = findPerson(id); return (r && r.pseudo) || shortId(id); + if(id === ST.me) return ST.pseudo || t("chat.you"); + const r = findPerson(id); return (r && (r.label || r.pseudo)) || shortId(id); } function convIsGroup(conv){ return !!conv && conv.startsWith("g:"); } function convName(conv){ if(convIsGroup(conv)){ - const g = ST.groups.find((x) => "g:" + x.id === conv); return g ? g.name : "Group"; + const g = ST.groups.find((x) => "g:" + x.id === conv); return g ? g.name : t("chat.group"); } return personName(conv); } @@ -607,6 +677,7 @@ if(!j) return; // keep what is on screen: it was true a moment ago ST.me = j.me; ST.pseudo = j.pseudo || ""; ST.bio = j.bio || ""; ST.has_avatar = !!j.has_avatar; ST.contacts = j.contacts || []; ST.known = j.known || []; ST.groups = j.groups || []; + ST.contacts_access = j.contacts_access || "unavailable"; UNREAD = j.unread || {}; TYPING = j.typing || {}; let touched = false; for(const m of (j.messages || [])){ @@ -614,7 +685,7 @@ if(m.conv === sel) touched = true; } if(typeof j.version === "number") VER = j.version; - renderMe(); renderList(); + renderMe(); renderPane(); renderPermCard(); if(sel){ renderHead(); if(touched) renderLog(); } } @@ -626,9 +697,10 @@ } function preview(m){ if(!m) return ""; - if(m.deleted) return "deleted message"; - if(m.kind === "image") return "Photo"; - if(m.kind === "file") return m.name || "File"; + if(m.deleted) return t("chat.deleted"); + if(m.kind === "image") return t("chat.photo"); + if(m.kind === "voice") return t("chat.voice.message"); + if(m.kind === "file") return m.name || t("chat.file"); return m.text || ""; } function clock(t){ @@ -640,11 +712,12 @@ for(const conv in MSGS){ const m = lastMsg(conv); if(m) out.push({conv, m, t:m.t || 0}); seen.add(conv); } for(const g of ST.groups){ const c = "g:" + g.id; if(!seen.has(c)){ out.push({conv:c, m:null, t:0}); seen.add(c); } } for(const c of ST.contacts){ if(!seen.has(c.id)){ out.push({conv:c.id, m:null, t:0}); seen.add(c.id); } } + for(const c of OPENED){ if(!seen.has(c)){ out.push({conv:c, m:null, t:0}); seen.add(c); } } out.sort((a, b) => b.t - a.t); return out; } function renderMe(){ - $("me-name").textContent = ST.pseudo || "Set your name"; + $("me-name").textContent = ST.pseudo || t("chat.set_name"); $("me-sub").textContent = ST.me ? shortId(ST.me) : ""; setHTML("me-av-slot", avatarHTML(ST.me, ST.pseudo)); } @@ -657,7 +730,7 @@ '' + esc(name) + "" + '' + (it.m ? esc(clock(it.m.t)) : "") + "" + '' + - (typing ? "typing…" : esc(preview(it.m))) + "" + + (typing ? "" + esc(t("chat.typing")) + "" : esc(preview(it.m))) + "" + (un ? '' + esc(un) + "" : "") + ""; } @@ -667,9 +740,8 @@ const host = $("chat-list"); if(!items.length){ setHTML(host, q - ? emptyHTML("Nothing matches that", "Try a shorter name, or paste a node id.") - : emptyHTML("No conversation yet", - "Start one with the pencil above, or by pasting someone's node id.")); + ? emptyHTML(t("chat.nomatch.title"), t("chat.nomatch.hint")) + : emptyHTML(t("chat.empty.title"), t("chat.empty.hint"))); return; } if(host.firstElementChild && !host.firstElementChild.classList.contains("ch-row")) @@ -706,12 +778,12 @@ renderHead(); if(changed) $("log").replaceChildren(); // a different conversation, not a redraw renderLog(true); - markRead(); renderList(); + markRead(); renderPane(); } function closeConv(){ sel = null; $("conv").hidden = true; $("empty").hidden = false; - view("list"); renderList(); + view("list"); renderPane(); } function markRead(){ if(!sel) return; @@ -723,15 +795,17 @@ setHTML("conv-av-slot", avatarHTML(convAvatarId(sel), name)); $("conv-title").textContent = name; let sub = ""; - if(TYPING[sel]) sub = "typing…"; + if(TYPING[sel]) sub = t("chat.typing"); else if(convIsGroup(sel)){ const g = ST.groups.find((x) => "g:" + x.id === sel); - sub = g ? plural(g.members.length, "member") : ""; + sub = g ? t("chat.members", {count: g.members.length}) : ""; }else{ const r = findPerson(sel); sub = (r && r.bio) ? r.bio : shortId(sel); } $("conv-sub").textContent = sub; $("info-btn").disabled = convIsGroup(sel); + const stranger = !convIsGroup(sel) && !ST.contacts.some((c) => c.id === sel); + $("save-contact").hidden = !stranger || ST.contacts_access === "unavailable"; } // ---- the log --------------------------------------------------------------- @@ -813,15 +887,17 @@ let quoted = null; for(const k in store) if(store[k].mid === m.reply) quoted = store[k]; if(quoted) html += '
' + - '' + esc(quoted.src === "me" ? "You" : personName(quoted.src)) + "" + + '' + esc(quoted.src === "me" ? t("chat.you") : personName(quoted.src)) + "" + '' + esc(preview(quoted)) + "
"; } if(m.deleted){ - html += '
deleted message
'; + html += '
' + esc(t("chat.deleted")) + "
"; }else if(m.kind === "image"){ html += '' + esc(m.name || "; if(m.text) html += '
' + linkify(m.text) + "
"; + }else if(m.kind === "voice"){ + html += voiceHTML(m); }else if(m.kind === "file"){ html += '"; } html += '
' + - (m.edited && !m.deleted ? 'edited' : "") + + (m.edited && !m.deleted ? '' + esc(t("chat.edited")) + "" : "") + esc(clock(m.t)) + (mine && !m.deleted ? tickHTML(m.status) : "") + "
"; html += reactsHTML(m) + ""; setHTML(el, html); + syncVoice(); } function reactsHTML(m){ const r = m.reactions || {}, keys = Object.keys(r); @@ -879,7 +956,7 @@ if(!sent){ if(!box.value.trim()){ box.value = text; autoGrow(); } replyTo = reply; setReplyBar(); - toast("That message was not sent — it is back in the box", "danger"); + toast(t("chat.err.not_sent"), "danger"); return; } toBottom($("log")); poll(); @@ -889,14 +966,14 @@ const conv = sel, reply = replyTo; let b64; try{ b64 = await toB64(file); } - catch(_){ toast("That file could not be read", "danger"); return; } + catch(_){ toast(t("chat.err.unreadable"), "danger"); return; } let sent = false; try{ const response = await api("/api/chat/file", "POST", {conv, name:file.name, data:b64, reply}); sent = response.ok; }catch(_){} - if(!sent){ toast(file.name + " was not sent", "danger"); return; } + if(!sent){ toast(t("chat.err.file_not_sent", {name: file.name}), "danger"); return; } replyTo = null; setReplyBar(); toBottom($("log")); poll(); } function toB64(file){ @@ -927,7 +1004,10 @@ box.style.height = "auto"; const max = parseFloat(getComputedStyle(box).maxHeight) || 160; box.style.height = Math.min(box.scrollHeight, max) + "px"; - $("send-btn").disabled = !box.value.trim(); + const has = !!box.value.trim(); + $("send-btn").hidden = !has && VOICE_OK; + $("send-btn").disabled = !has; + $("mic-btn").hidden = has || !VOICE_OK; } // ---- reply, context menu, reactions --------------------------------------- @@ -940,7 +1020,7 @@ const bar = $("reply-bar"); const rec = replyTo ? recordFor(replyTo) : null; if(!rec){ replyTo = null; bar.hidden = true; return; } - $("reply-who").textContent = rec.src === "me" ? "You" : personName(rec.src); + $("reply-who").textContent = rec.src === "me" ? t("chat.you") : personName(rec.src); $("reply-text").textContent = preview(rec); bar.hidden = false; $("msg").focus(); } @@ -949,10 +1029,11 @@ if(!rec || rec.deleted) return; const mine = rec.src === "me", ctx = $("ctx"); setHTML(ctx, - '' + - (rec.kind === "text" ? '' : "") + - (mine && rec.kind === "text" ? '' : "") + - (mine ? '' : "")); + '" + + (rec.kind === "text" ? '" : "") + + (mine && rec.kind === "text" ? '" : "") + + (mine ? '" : "")); ctx.dataset.mid = mid; ctx.hidden = false; const w = ctx.offsetWidth || 180, h = ctx.offsetHeight || 160; ctx.style.left = Math.max(8, Math.min(x, innerWidth - w - 8)) + "px"; @@ -966,9 +1047,9 @@ if(action === "reply"){ replyTo = mid; setReplyBar(); } else if(action === "copy"){ copyText(rec.text || ""); } else if(action === "edit"){ editMessage(mid, rec); }else if(action === "delete"){ - confirmAction({title:"Delete this message for everyone?", - body:'

It is replaced by a tombstone on every node that has it.

', - confirmLabel:"Delete", danger:true}).then((yes) => { + confirmAction({title:t("chat.delete.title"), + body:'

' + esc(t("chat.delete.body")) + "

", + confirmLabel:t("common.delete"), danger:true}).then((yes) => { if(yes) api("/api/chat/delete", "POST", {conv:sel, mid}).then(poll); }); }else if(action === "react"){ openEmoji(mid); } @@ -979,16 +1060,16 @@ function editMessage(mid, rec){ const conv = sel; confirmAction({ - title:"Edit this message", - confirmLabel:"Save", - body:'
+""" + ui.CTX_BAR + """ + + + +
+

+
+
+ +
+
+
+
+
+
+
+ + +
+

+
+
+ +
+
+
+
+
+
+ + +
+

+
+
+

+
+
+
+ + + +
+
+
+

+
+
+
+ + +
+
+
+
+ + + + + +
+
+
+ + +
+

+
+
+ + +
+
+
+ +
+ + + +""" + +CONTACTS_PAGE_CSS = """ +/* Every grid track is minmax(0,…) and every text holder min-width:0 — the two + rules the chat page learned the hard way (a long word otherwise widens the + page past a phone). */ +:root{--ct-books-w:248px; --ct-list-w:340px} +.ct{display:grid;height:100dvh;overflow:hidden;background:var(--canvas); + grid-template-columns:var(--ct-books-w) minmax(0,var(--ct-list-w)) minmax(0,1fr)} +.ct>*{min-width:0;min-height:0} +.ct-books,.ct-list,.ct-detail{display:flex;flex-direction:column} +.ct-books{background:var(--rail);border-right:1px solid var(--border)} +.ct-list{border-right:1px solid var(--border);background:var(--surface)} +.ct-head{display:flex;align-items:center;gap:var(--s-1);padding:var(--s-3); + border-bottom:1px solid var(--border);min-width:0} +.ct-title{font-size:var(--fs-md);font-weight:650;margin:0} +.ct-bookname{flex:1 1 auto;min-width:0;display:flex;flex-direction:column} +.ct-bookname h2{margin:0;font-size:var(--fs-md);font-weight:650} +.ct-foot{padding:var(--s-3) var(--s-3) calc(var(--s-3) + env(safe-area-inset-bottom)); + border-top:1px solid var(--border)} +.ct-back{display:none} + +.ct-booklist,.ct-rows{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden; + padding:var(--s-2);overscroll-behavior:contain} +.ct-book,.ct-row{display:flex;align-items:center;gap:var(--s-3);width:100%;min-width:0; + padding:var(--s-2) var(--s-3);border:0;background:transparent;text-align:left; + border-radius:var(--r-md);cursor:pointer} +.ct-book:hover,.ct-row:hover{background:var(--surface-2);border-color:transparent} +.ct-book[aria-current="true"],.ct-row[aria-current="true"]{background:var(--accent-soft)} +.ct-dot{width:12px;height:12px;border-radius:50%;flex:none;background:var(--accent)} +.ct-dot[data-c="green"]{background:var(--ok,#2da44e)} +.ct-dot[data-c="teal"]{background:#1b9e9e} +.ct-dot[data-c="amber"]{background:#d29922} +.ct-dot[data-c="red"]{background:var(--danger,#cf222e)} +.ct-dot[data-c="purple"]{background:#8250df} +.ct-dot[data-c="pink"]{background:#bf3989} +.ct-dot[data-c="gray"]{background:var(--text-faint)} +.ct-body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px} +.ct-name{font-weight:600;font-size:var(--fs-sm)} +.ct-sub{font-size:var(--fs-xs);color:var(--text-muted)} +.ct-av{width:36px;height:36px;flex:none;border-radius:50%;display:grid;place-items:center; + background:var(--accent-soft);color:var(--accent);font:700 var(--fs-xs)/1 var(--font)} +.ct-fav{flex:none;color:var(--accent);width:1em;height:1em} + +.ct-offers{padding:var(--s-2);display:flex;flex-direction:column;gap:var(--s-2); + border-bottom:1px solid var(--border)} +.ct-offer{padding:var(--s-3);border:1px solid var(--border);border-radius:var(--r-md); + background:var(--surface);display:flex;flex-direction:column;gap:var(--s-2)} +.ct-offer .btn-row{display:flex;gap:var(--s-2)} + +.ct-search{padding:var(--s-3)} +.ct-search .search{max-width:none;width:100%} +.ct-detail{background:var(--canvas)} +.ct-blank{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center; + gap:var(--s-3);padding:var(--s-6);text-align:center} +.ct-blank .t{font-size:var(--fs-lg);font-weight:620} +.ct-blank .h{color:var(--text-muted);max-width:44ch} +.ct-editor{display:flex;flex-direction:column;flex:1 1 auto;min-height:0} +.ct-editor[hidden],.ct-blank[hidden]{display:none} +.ct-editor-body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:var(--s-4); + display:flex;flex-direction:column;gap:var(--s-4);max-width:640px;width:100%} +.ct-editor-foot{display:flex;gap:var(--s-2);align-items:center;padding:var(--s-3) var(--s-4) + calc(var(--s-3) + env(safe-area-inset-bottom));border-top:1px solid var(--border); + background:var(--surface)} +.ct-nodes{display:flex;flex-direction:column;gap:var(--s-2)} +.ct-node{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,9rem) auto;gap:var(--s-2);align-items:center} +.ct-node-name{grid-column:1 / -1;font-size:var(--fs-xs);color:var(--text-muted)} +#fav[aria-pressed="true"]{color:var(--accent)} +#fav[aria-pressed="true"] .ic{fill:currentColor} + +.ct-people,.ct-members{display:flex;flex-direction:column;gap:var(--s-1);margin:var(--s-2) 0} +.ct-person{display:flex;align-items:center;gap:var(--s-3);padding:var(--s-2); + border-radius:var(--r-md);min-width:0} +.ct-person:hover{background:var(--surface-2)} +.ct-person .btn-row{display:flex;gap:var(--s-1);flex:none} +.ct-colors{display:flex;gap:var(--s-2);flex-wrap:wrap} +.ct-colors button{width:28px;height:28px;border-radius:50%;padding:0;border:2px solid transparent} +.ct-colors button[aria-checked="true"]{border-color:var(--text)} +.ct-menu{display:flex;flex-direction:column;gap:var(--s-2)} + +/* Wide enough for all three panes: no pane is ever hidden. */ +@media (max-width: 1000px){ + .ct{grid-template-columns:minmax(0,1fr)} + .ct[data-view="books"] .ct-list,.ct[data-view="books"] .ct-detail, + .ct[data-view="list"] .ct-books,.ct[data-view="list"] .ct-detail, + .ct[data-view="detail"] .ct-books,.ct[data-view="detail"] .ct-list{display:none} + .ct-back{display:inline-flex} + .ct-books{border-right:0}.ct-list{border-right:0} + .ct-editor-body{max-width:none} +} +@media (min-width: 1001px){ + #to-books,#to-list{display:none} +} +""" + +CONTACTS_PAGE_JS = r""" +// ── contacts page ─────────────────────────────────────────────────────────── +// Polled like chat, rendered the same way: keyed rows, and the editor is never +// rebuilt under somebody who is typing in it. + +const COLORS = ["blue","green","teal","amber","red","purple","pink","gray"]; +let ST = {me:"", books:[], offers:[], contacts:[], names:{}, book:""}; +let bookId = "", current = null, draft = null, dirty = false, timer = null, hits = []; +let formMode = "create"; + +SESSION.onLost = () => { + if(timer){ clearInterval(timer); timer = null; } + EVENTS.stop(); + $("app").classList.add("hidden"); $("login").classList.remove("hidden"); +}; +SESSION.load(); + +function view(next){ + const app = $("app"); + if(next) app.dataset.view = next; + return app.dataset.view; +} +const initials = (s) => { s = (s || "").trim(); return s ? s.slice(0, 2).toUpperCase() : "?"; }; +const bookOf = (id) => ST.books.find((b) => b.id === id) || null; +const isNode = (s) => /^[0-9a-f]{40}$/.test(s); +const nodeName = (id) => ST.names[id] || ""; + +// What a refusal is called is the page's word, never the node's: the node sends +// a stable code (`why`) and each language says it in its own. +function failure(body){ + if(body && body.why && STRINGS["contacts.err." + body.why]) return t("contacts.err." + body.why); + if(body && body.error === "permission") return t("contacts.err.permission"); + return t("common.failed"); +} +async function call(op, data){ + let res; + try{ res = await api("/api/contacts/" + op, "POST", data || {}); } + catch(error){ if(isStale(error)) throw error; return {ok:false, body:{}}; } + const body = await res.json().catch(() => ({})); + return {ok: res.ok && body.ok !== false, body}; +} + +// ---- polling --------------------------------------------------------------- +async function poll(){ + let j; + try{ j = await FEED.read("/api/contacts/state", {book: bookId}); } + catch(_){ j = null; } + if(!j) return; + ST = Object.assign(ST, j); + if(!bookId || !bookOf(bookId)) bookId = j.book || ""; + renderBooks(); renderOffers(); renderBook(); renderRows(); + if(current && current !== "new" && !dirty){ + const found = ST.contacts.find((c) => c.id === current); + if(found) fillEditor(found, true); else closeEditor(); + } +} +function pickBook(id){ + bookId = id; current = null; closeEditor(); view("list"); + $("search").value = ""; + poll(); +} + +// ---- books ----------------------------------------------------------------- +function roleText(b){ + if(b.role === "owner") return b.shared ? t("contacts.book.shared_out") : t("contacts.book.private"); + return b.role === "write" ? t("contacts.book.shared_edit") : t("contacts.book.shared_read"); +} +function renderBooks(){ + const host = $("book-list"); + if(!ST.books.length){ setHTML(host, emptyHTML(t("contacts.none.title"), t("contacts.none.hint"))); return; } + setHTML(host, ST.books.map((b) => + '").join("")); +} +function renderBook(){ + const b = bookOf(bookId); + $("book-title").textContent = b ? b.name : ""; + $("book-sub").textContent = b ? roleText(b) + (b.role !== "owner" && nodeName(b.owner) + ? " · " + nodeName(b.owner) : "") : ""; + $("add").disabled = !b || !b.writable; + $("book-share").hidden = !b || b.role !== "owner"; + $("book-share").innerHTML = icon("share"); + $("book-menu").innerHTML = icon("dots"); + $("f-move-field").hidden = !b || !b.writable || + ST.books.filter((x) => x.writable && x.id !== bookId).length === 0; +} +function renderOffers(){ + const host = $("offers"); + host.hidden = !ST.offers.length; + setHTML(host, ST.offers.map((o) => + '
' + esc(o.name) + '
' + + esc(t("contacts.offer.from", {who: nodeName(o.from) || shortId(o.from)})) + " · " + + esc(o.role === "write" ? t("contacts.role.write") : t("contacts.role.read")) + " · " + + esc(t("count.contact", {count: o.count})) + '
' + + '
' + + '
").join("")); +} + +// ---- the rows -------------------------------------------------------------- +function rowName(c){ return c.name || nodeName(c.nodes[0] && c.nodes[0].id) || shortId(c.nodes[0] && c.nodes[0].id); } +function renderRows(){ + const q = ($("search").value || "").trim().toLowerCase(); + const rows = ST.contacts.filter((c) => !q || (rowName(c) + " " + c.note + " " + + c.nodes.map((n) => n.id + " " + n.label + " " + nodeName(n.id)).join(" ")).toLowerCase().includes(q)); + const host = $("rows"); + if(!rows.length){ + setHTML(host, q ? emptyHTML(t("contacts.nomatch.title"), t("contacts.nomatch.hint")) + : emptyHTML(t("contacts.empty.title"), t("contacts.empty.hint"))); + return; + } + setHTML(host, rows.map((c) => + '").join("")); +} + +// ---- the editor ------------------------------------------------------------ +function nodeRow(n){ + const known = nodeName(n.id); + return '
' + + '' + + '" + + (known ? '
' + esc(t("contacts.node.known", {name: known})) + "
" : "") + + "
"; +} +function paintNodes(){ + setHTML("f-nodes", draft.nodes.map(nodeRow).join("")); +} +function readNodes(){ + draft.nodes = $$(".ct-node", $("f-nodes")).map((row) => ({ + id: row.querySelector('[data-f="id"]').value.trim().toLowerCase(), + label: row.querySelector('[data-f="label"]').value.trim()})); +} +function setFav(on){ + draft.fav = !!on; + $("fav").setAttribute("aria-pressed", String(!!on)); + $("fav").innerHTML = icon("star"); +} +function fillEditor(entry, quiet){ + const b = bookOf(bookId); + const writable = !!(b && b.writable); + draft = {id: entry.id || "", name: entry.name || "", note: entry.note || "", fav: !!entry.fav, + nodes: (entry.nodes || []).map((n) => ({id:n.id, label:n.label || ""}))}; + $("blank").hidden = true; $("editor").hidden = false; + $("editor-title").textContent = entry.id ? rowName(entry) : t("contacts.new"); + $("f-name").value = draft.name; $("f-note").value = draft.note; + paintNodes(); setFav(draft.fav); + for(const el of $$("input,textarea", $("editor"))) el.disabled = !writable; + $("save").hidden = $("del").hidden = $("node-add").hidden = !writable; + $("fav").disabled = !writable; + const first = (draft.nodes[0] || {}).id; + $("chat-link").hidden = !first; + if(first) $("chat-link").setAttribute("href", "/chat#c/" + first); + fillMove(); + setMessage("editor-msg", writable ? "" : t("contacts.err.read_only")); + dirty = false; + if(!quiet) view("detail"); +} +function fillMove(){ + const targets = ST.books.filter((x) => x.writable && x.id !== bookId); + setHTML("f-move", '" + + targets.map((x) => '").join("")); +} +function closeEditor(){ + current = null; draft = null; dirty = false; + $("editor").hidden = true; $("blank").hidden = false; + if(view() === "detail") view("list"); + renderRows(); +} +function openContact(id){ + const entry = ST.contacts.find((c) => c.id === id); + if(!entry) return; + current = id; fillEditor(entry); renderRows(); +} +function newContact(seed){ + current = "new"; + fillEditor(Object.assign({id:"", name:"", note:"", fav:false, nodes:[]}, seed || {})); + dirty = true; + $("f-name").focus(); + renderRows(); +} +async function saveContact(event){ + event.preventDefault(); + if(!draft) return; + readNodes(); + draft.name = $("f-name").value.trim(); draft.note = $("f-note").value.trim(); + const nodes = draft.nodes.filter((n) => n.id || n.label); + if(nodes.some((n) => !isNode(n.id))){ setMessage("editor-msg", t("contacts.err.bad_node"), true); return; } + const payload = {book: bookId, name: draft.name, nodes, note: draft.note, fav: draft.fav}; + if(draft.id) payload.id = draft.id; + await withBusy($("save"), async () => { + const r = await call("save", payload); + if(!r.ok){ setMessage("editor-msg", failure(r.body), true); return; } + const target = $("f-move").value; + if(target && r.body.contact) + await call("move", {book: bookId, to: target, id: r.body.contact.id}); + toast(t("common.saved")); + dirty = false; current = r.body.contact ? r.body.contact.id : null; + if(target){ closeEditor(); } + await poll(); + if(current) openContact(current); + }); +} +async function deleteContact(){ + if(!draft || !draft.id) { closeEditor(); return; } + const ok = await confirmAction({title: t("contacts.delete.title"), + body: "

" + esc(t("contacts.delete.body", {name: rowName(draft)})) + "

", + confirmLabel: t("common.delete"), danger: true}); + if(!ok) return; + const r = await call("delete", {book: bookId, id: draft.id}); + if(!r.ok){ toast(failure(r.body), "danger"); return; } + closeEditor(); poll(); +} + +// ---- books: create, rename, delete, share ----------------------------------- +function openBookForm(mode){ + formMode = mode; + const b = mode === "rename" ? bookOf(bookId) : null; + $("bookform-title").textContent = t(mode === "rename" ? "contacts.book.rename" : "contacts.book.new"); + $("bf-name").value = b ? b.name : ""; + paintColors(b ? b.color : "blue"); + $("bookform").showModal(); + $("bf-name").focus(); +} +function paintColors(chosen){ + setHTML("bf-colors", COLORS.map((c) => + '').join("")); +} +async function saveBookForm(){ + const name = $("bf-name").value.trim(); + if(!name) return; + const picked = document.querySelector('#bf-colors [aria-checked="true"]'); + const chosen = picked ? picked.dataset.v : "blue"; + const r = formMode === "rename" + ? await call("book", {action:"rename", book: bookId, name, color: chosen}) + : await call("book", {action:"create", name, color: chosen}); + if(!r.ok){ toast(failure(r.body), "danger"); return; } + $("bookform").close(); + if(r.body.book) bookId = r.body.book; + poll(); +} +async function deleteBook(){ + const b = bookOf(bookId); if(!b) return; + $("menu-dialog").close(); + const ok = await confirmAction({ + title: t(b.role === "owner" ? "contacts.book.delete_title" : "contacts.book.leave_title"), + body: "

" + esc(t(b.role === "owner" ? "contacts.book.delete_body" : "contacts.book.leave_body", + {name: b.name})) + "

", + confirmLabel: t(b.role === "owner" ? "common.delete" : "contacts.book.leave"), danger: true}); + if(!ok) return; + const r = await call("book", {action:"delete", book: bookId}); + if(!r.ok){ toast(failure(r.body), "danger"); return; } + bookId = ""; closeEditor(); view("books"); poll(); +} +function memberRows(b){ + const rows = Object.keys(b.members || {}).map((node) => { + const m = b.members[node]; + return '
' + + esc(nodeName(node) || shortId(node)) + '' + + esc(m.state === "offered" ? t("contacts.share.pending") : t("contacts.share.active")) + " · " + + esc(m.role === "write" ? t("contacts.role.write") : t("contacts.role.read")) + "" + + '" + + '
"; + }); + return rows.length ? rows.join("") : '

' + esc(t("contacts.share.nobody")) + "

"; +} +function openShare(){ + const b = bookOf(bookId); if(!b || b.role !== "owner") return; + setHTML("share-members", memberRows(b)); + setMessage("share-msg", ""); + $("share").showModal(); +} +async function invite(node){ + node = (node || "").trim().toLowerCase(); + if(!isNode(node)){ setMessage("share-msg", t("contacts.err.bad_node"), true); return; } + const r = await call("share", {book: bookId, node, role: $("sh-role").value}); + if(!r.ok){ setMessage("share-msg", failure(r.body), true); return; } + setMessage("share-msg", t(r.body.sent ? "contacts.share.sent" : "contacts.share.queued")); + $("sh-node").value = ""; setHTML("sh-hits", ""); + await poll(); setHTML("share-members", memberRows(bookOf(bookId))); +} + +// ---- people: everyone on the network who answers to a name ------------------ +async function findPeople(q){ + q = (q || "").trim(); + const host = $("people-rows"); + if(!q){ setHTML(host, ""); return; } + setHTML(host, skeletonHTML(3)); + const r = await apiJson("/api/contacts/people?q=" + encodeURIComponent(q)); + hits = (r.data && r.data.results) || []; + if(!hits.length){ setHTML(host, emptyHTML(t("contacts.people.none"), t("contacts.people.none_hint"))); return; } + setHTML(host, hits.map((h, i) => personRow(h, i)).join("")); +} +function personRow(h, i){ + if(h.kind === "contact") + return '
' + esc(initials(h.name)) + '' + + '' + esc(h.name) + '' + + esc(h.book_name) + '' + esc(t("contacts.people.saved")) + "
"; + return '
' + esc(initials(h.pseudo)) + '' + + '' + esc(h.pseudo) + '' + + esc(shortId(h.node)) + '' + + (h.saved ? '' + esc(t("contacts.people.saved")) + "" + : '") + "
"; +} +function openPeople(){ + $("people-q").value = ""; $("people-id").value = ""; setHTML("people-rows", ""); + $("people").showModal(); $("people-q").focus(); +} +function addFromPeople(node, name){ + $("people").close(); + newContact({name: name || "", nodes: [{id: node, label: ""}]}); +} + +// ---- import / export ------------------------------------------------------- +async function exportBook(){ + $("menu-dialog").close(); + const res = await api("/api/contacts/export?book=" + encodeURIComponent(bookId)); + if(!res.ok){ toast(t("common.failed"), "danger"); return; } + const b = bookOf(bookId); + saveBytes((b ? b.name : "contacts").replace(/[^A-Za-z0-9._-]+/g, "_") + ".vcf", + new Uint8Array(await res.arrayBuffer()), "text/vcard"); +} +async function importFile(file){ + $("menu-dialog").close(); + if(!file) return; + const text = await file.text(); + const r = await call("import", {book: bookId, text}); + if(!r.ok){ toast(failure(r.body), "danger"); return; } + toast(t("contacts.import.done", {count: r.body.added}), + "", r.body.skipped ? t("contacts.import.skipped", {count: r.body.skipped}) : ""); + poll(); +} + +// ---- wiring ---------------------------------------------------------------- +function bind(){ + $("book-new").innerHTML = icon("plus"); + $("book-new").addEventListener("click", () => openBookForm("create")); + $("to-books").innerHTML = icon("back"); $("to-list").innerHTML = icon("back"); + $("to-books").addEventListener("click", () => view("books")); + $("to-list").addEventListener("click", () => { closeEditor(); view("list"); }); + $("fav").innerHTML = icon("star"); + $("fav").addEventListener("click", () => { if(draft){ setFav(!draft.fav); dirty = true; } }); + $$("[data-close]").forEach((b) => { b.innerHTML = icon("close"); + b.addEventListener("click", () => $(b.dataset.close).close()); }); + $("book-list").addEventListener("click", (e) => { + const b = e.target.closest(".ct-book"); if(b) pickBook(b.dataset.id); + }); + $("offers").addEventListener("click", async (e) => { + const b = e.target.closest("button"); if(!b) return; + const r = await call("answer", {book: b.dataset.b, from: b.dataset.f, accept: b.dataset.a === "yes"}); + if(!r.ok) toast(failure(r.body), "danger"); + poll(); + }); + $("rows").addEventListener("click", (e) => { + const r = e.target.closest(".ct-row"); if(r) openContact(r.dataset.id); + }); + $("search").addEventListener("input", debounce(renderRows, 120)); + $("add").addEventListener("click", openPeople); + $("editor").addEventListener("submit", saveContact); + $("editor").addEventListener("input", () => { dirty = true; }); + $("del").addEventListener("click", deleteContact); + $("node-add").addEventListener("click", () => { + readNodes(); draft.nodes.push({id:"", label:""}); paintNodes(); + const last = $$(".ct-node input", $("f-nodes")); if(last.length) last[last.length - 2].focus(); + }); + $("f-nodes").addEventListener("click", (e) => { + const b = e.target.closest('[data-a="drop"]'); if(!b) return; + readNodes(); const at = $$(".ct-node", $("f-nodes")).indexOf(b.closest(".ct-node")); + draft.nodes.splice(at, 1); paintNodes(); dirty = true; + }); + $("people-q").addEventListener("input", debounce((e) => findPeople(e.target.value), 300)); + $("people-rows").addEventListener("click", (e) => { + const b = e.target.closest('[data-a="pick"]'); if(!b) return; + const h = hits[Number(b.dataset.i)]; if(h) addFromPeople(h.node, h.pseudo); + }); + $("people-add").addEventListener("click", () => { + const id = $("people-id").value.trim().toLowerCase(); + if(isNode(id)) addFromPeople(id, nodeName(id)); else toast(t("contacts.err.bad_node"), "warn"); + }); + $("bf-colors").addEventListener("click", (e) => { + const b = e.target.closest("button"); if(b) paintColors(b.dataset.v); + }); + $("bf-save").addEventListener("click", saveBookForm); + $("book-share").addEventListener("click", openShare); + $("sh-send").addEventListener("click", () => invite($("sh-node").value)); + $("sh-node").addEventListener("input", debounce(async (e) => { + const q = e.target.value.trim(); + if(q.length < 2 || isNode(q.toLowerCase())){ setHTML("sh-hits", ""); return; } + const r = await apiJson("/api/contacts/people?q=" + encodeURIComponent(q)); + hits = ((r.data && r.data.results) || []).filter((h) => h.kind === "node"); + setHTML("sh-hits", hits.map((h, i) => '
' + + esc(h.pseudo) + '' + esc(shortId(h.node)) + '' + + '
").join("")); + }, 300)); + $("sh-hits").addEventListener("click", (e) => { + const b = e.target.closest('[data-a="invite"]'); if(!b) return; + const h = hits[Number(b.dataset.i)]; if(h) invite(h.node); + }); + $("share-members").addEventListener("click", async (e) => { + const b = e.target.closest("button"); if(!b) return; + const r = b.dataset.a === "revoke" + ? await call("unshare", {book: bookId, node: b.dataset.n}) + : await call("share", {book: bookId, node: b.dataset.n, role: b.dataset.r}); + if(!r.ok) setMessage("share-msg", failure(r.body), true); + await poll(); setHTML("share-members", memberRows(bookOf(bookId))); + }); + $("book-menu").addEventListener("click", () => { + const b = bookOf(bookId); if(!b) return; + $("m-rename").hidden = b.role !== "owner"; + $("m-default").hidden = b.default || b.role !== "owner"; + $("m-import-label").hidden = !b.writable; + $("m-delete").textContent = t(b.role === "owner" ? "contacts.book.delete" : "contacts.book.leave"); + $("menu-dialog").showModal(); + }); + $("m-rename").addEventListener("click", () => { $("menu-dialog").close(); openBookForm("rename"); }); + $("m-default").addEventListener("click", async () => { + $("menu-dialog").close(); await call("book", {action:"default", book: bookId}); poll(); + }); + $("m-export").addEventListener("click", exportBook); + $("m-import").addEventListener("change", (e) => { importFile(e.target.files[0]); e.target.value = ""; }); + $("m-delete").addEventListener("click", deleteBook); + PALETTE.add(t("contacts.add"), t("palette.action"), openPeople); + PALETTE.add(t("contacts.book.new"), t("palette.action"), () => openBookForm("create")); + PALETTE.add(t("common.back_to_console"), t("palette.goto"), () => { window.location = "/"; }); +} + +// ---- auth and boot --------------------------------------------------------- +async function enter(token){ + const headers = {}; + if(token) headers["Authorization"] = "Bearer " + token; + const res = await fetch("/api/contacts/state", {headers}); + if(!res.ok) return false; + if(token) SESSION.set(token); + $("login").classList.add("hidden"); $("app").classList.remove("hidden"); + mountShell(); + CONTEXT.confirm(); + REFRESH.mount(); + await poll(); + if(timer) clearInterval(timer); + timer = setInterval(poll, 2000); + return true; +} +$("login-form").addEventListener("submit", async (e) => { + e.preventDefault(); setMessage("err", ""); + await withBusy(e.submitter || $("login-form").querySelector("button"), async () => { + try{ + const res = await fetch("/api/login", {method:"POST", + headers:{"Content-Type":"application/json"}, + body:JSON.stringify({password:$("password").value})}); + if(!res.ok){ + const body = await res.json().catch(() => ({})); + setMessage("err", body.error || t("login.failed"), true); return; + } + $("password").value = ""; + await enter((await res.json()).token); + }catch(_){ setMessage("err", t("login.unreachable"), true); } + }); +}); +bind(); +(function(){ + let token = null; + try{ token = sessionStorage.getItem("nmesh_token"); }catch(_){} + enter(token).then((ok) => { if(!ok) $("login").classList.remove("hidden"); }); +})(); +""" diff --git a/src/webassets/nodeview.py b/src/webassets/nodeview.py index 9d8d81a0..49cfc275 100644 --- a/src/webassets/nodeview.py +++ b/src/webassets/nodeview.py @@ -328,7 +328,7 @@ }, async extras(id, self){ - const out = {chat:null, fleet:null, packages:[]}; + const out = {chat:null, fleet:null, contacts:null, packages:[]}; // What this machine publishes is asked of every node, its own included: a // node that published its own code is exactly the one an operator wants to // see it on. Answered from what this node already holds — the button below @@ -339,6 +339,11 @@ if(this.has("chat", "peer")) jobs.push(this.call("chat", "peer", {node:id}) .then((value) => { out.chat = value; }).catch(() => {})); + // Whether somebody is in the address books is the contacts app's answer, not + // chat's: chat only keeps a copy of it, and only where it was allowed to. + if(this.has("contacts", "find")) + jobs.push(this.call("contacts", "find", {node:id}) + .then((value) => { out.contacts = value; }).catch(() => {})); if(this.has("fleet", "relation")) jobs.push(this.call("fleet", "relation", {node:id}) .then((value) => { out.fleet = value; }).catch(() => {})); @@ -346,6 +351,13 @@ return out; }, + // In the address books, as the contacts app says; chat's own copy of that + // answer only where the contacts app is not running to ask. + saved(extras){ + if(extras.contacts) return (extras.contacts.contacts || []).length > 0; + return !!(extras.chat && extras.chat.contact); + }, + // -- drawing -------------------------------------------------------------- // Drawn once, then written into. This card repaints every tick, and rewriting // its markup shut every fold the reader had opened, dropped what they were @@ -378,7 +390,7 @@ view.addresses.map((row) => row.uri), (extras.packages || []).map( (row) => row.id + ":" + row.version + ":" + (row.published ? "p" : "s")), - (options.hide || []), !!extras.chat, !!chat.contact, !!chat.seen, + (options.hide || []), !!extras.chat, this.saved(extras), !!chat.seen, !!chat.unread, !!extras.fleet, !!fleet.managed, !!fleet.operator, (fleet.caps || []), (fleet.operator_caps || []), !!fleet.waiting_on_them, !!fleet.waiting_on_us, (fleet.asked_caps || []), @@ -493,7 +505,7 @@ else say("link", "Reached through the mesh — no direct link to it."); - if(chat && chat.contact) + if(this.saved(extras)) say("person", "In your contacts" + (chat.unread ? ", with " + plural(chat.unread, "unread message") : "") + "."); else if(chat && chat.seen) @@ -585,7 +597,7 @@ icon("copy") + "Copy the identity", '"]; - if(chat && !chat.contact) + if(this.has("contacts", "add") && !this.saved(extras)) items.push('"); if(fleet && fleet.managed && (fleet.caps || []).indexOf("invite") >= 0) @@ -1136,7 +1148,7 @@ async contact(element, button, id, options){ await withBusy(button, async () => { try{ - await this.call("chat", "contact", {node:id}); + await this.call("contacts", "add", {node:id}); toast(t("node.added_to_contacts"), "ok"); await this.mount(element, id, options); }catch(error){ this.say(element, String(error.message || error), true); } diff --git a/src/webassets/ui.py b/src/webassets/ui.py index a9547d7a..aede3c5f 100644 --- a/src/webassets/ui.py +++ b/src/webassets/ui.py @@ -1060,6 +1060,20 @@ def ctx_bar(leave: bool = True) -> str: arrowDown: '', arrowLeft: '', arrowRight: '', + plus: '', + search: '', + star: '', + share: '', + download: '', + upload: '', + book: '', + userAdd: '', + users: '', + lock: '', + mic: '', + play: '', + pause: '', + stop: '', }; // `title` is what a screen reader announces; without one the icon is decorative // and hidden, because a button beside it already carries the label. @@ -1149,7 +1163,7 @@ def ctx_bar(leave: bool = True) -> str: // may reach it, so `CHANNEL` needs no list at all (`src/control/plane.py`). // These are the routes that have not moved onto the plane yet. const ALWAYS_HERE = ["/api/remote/", "/api/login", "/api/logout", "/api/session"]; -const NEEDS_GRANT = [["/api/chat/", "apps"], ["/api/fleet/", "full"]]; +const NEEDS_GRANT = [["/api/chat/", "apps"], ["/api/contacts/", "apps"], ["/api/fleet/", "full"]]; const local = (path) => ALWAYS_HERE.some((prefix) => path.startsWith(prefix)) || NEEDS_GRANT.some(([prefix, grant]) => path.startsWith(prefix) && !CONTEXT.caps.includes(grant)); @@ -1317,8 +1331,11 @@ class StaleContext extends Error { // The note is only true while the grant the page needs is missing. const note = $("ctx-note"); const needs = document.body.dataset.ctxNeeds || ""; - if(note) note.textContent = needs && this.caps.includes(needs) - ? "" : (document.body.dataset.ctxLocal || ""); + // The sentence is a catalogue key where the page names one, so a page in + // another language does not carry the English one in its markup. + const said = document.body.dataset.ctxLocalKey + ? t(document.body.dataset.ctxLocalKey) : (document.body.dataset.ctxLocal || ""); + if(note) note.textContent = needs && this.caps.includes(needs) ? "" : said; document.body.dataset.appName = this.remote ? "NMesh — " + (this.label || shortId(this.node)) : (document.body.dataset.appHome || document.body.dataset.appName || "NMesh"); diff --git a/src/webconsole.py b/src/webconsole.py index 14f6d6cc..27696aa1 100644 --- a/src/webconsole.py +++ b/src/webconsole.py @@ -53,7 +53,7 @@ from .webassets import (NODE_HTML, NODE_JS, NODE_CSS, PKG_HTML, PKG_JS, PKG_CSS, INDEX_HTML, APP_JS, STYLE_CSS, CHAT_HTML, CHAT_JS, - CHAT_CSS, FLEET_HTML, FLEET_JS, FLEET_CSS, + CHAT_CSS, CONTACTS_HTML, CONTACTS_JS, CONTACTS_CSS, FLEET_HTML, FLEET_JS, FLEET_CSS, TERM_HTML, TERM_JS, TERM_CSS) from .webassets.ui import FAVICON_SVG, THEME_JS from .apps.fleet import (console_path_refusal as fleet_console_refusal, @@ -124,6 +124,7 @@ def _safe_filename(name) -> str: _MAX_BODY = 64 * 1024 _MAX_APP_BODY = 4 * 1024 * 1024 # larger cap for app publish uploads +_MAX_CONTACTS_IMPORT = 2 * 1024 * 1024 # a vCard file pasted into the contacts page _MAX_CHAT_UPLOAD = 64 * 1024 * 1024 # chat file/avatar uploads (base64) _MAX_KEY_UPLOAD = 128 * 1024 # one private key, generously bounded # One file pushed to a managed node. `fleet_files.MAX_TRANSFER` (32 MiB) is what @@ -285,7 +286,7 @@ class _WebSurface: # Which bundles make up each app's page. The terminal is fleet's: its # file browser is the `shell` right through another door. - _BUNDLES = {"/chat": ("CHAT_JS",), "/fleet": ("FLEET_JS", "TERM_JS")} + _BUNDLES = {"/chat": ("CHAT_JS",), "/contacts": ("CONTACTS_JS",), "/fleet": ("FLEET_JS", "TERM_JS")} def __init__(self, console) -> None: self._console = console @@ -656,6 +657,11 @@ def _chat(self): return self._app_host.bridge("chat") return self._chat_bridge + @property + def _contacts(self): + """The live contacts bridge, or None when the app is disabled/absent.""" + return self._app_host.bridge("contacts") if self._app_host else None + @property def _fleet(self): """The live fleet bridge, or None when the app is disabled/absent.""" @@ -973,6 +979,14 @@ def _generate_self_signed(host: str) -> tuple[bytes, bytes]: "/chat.css": ("text/css; charset=utf-8", CHAT_CSS), } +# Contacts sub-page assets, served only when the contacts app is running. Same +# rule as chat: the page is public, every /api/contacts/* call needs the session. +_CONTACTS_STATIC = { + "/contacts": ("text/html; charset=utf-8", CONTACTS_HTML), + "/contacts.js": ("application/javascript; charset=utf-8", CONTACTS_JS), + "/contacts.css": ("text/css; charset=utf-8", CONTACTS_CSS), +} + # Fleet sub-page assets, served only when the fleet app is running. Same rule as # chat: the page itself is public, every /api/fleet/* call needs the session. _FLEET_STATIC = { @@ -1363,10 +1377,17 @@ def _get(self) -> None: ctype, text = _CHAT_STATIC[path] self._send(200, ctype, text.encode("utf-8")) return + if console._contacts is not None and path in _CONTACTS_STATIC: + ctype, text = _CONTACTS_STATIC[path] + self._send(200, ctype, text.encode("utf-8")) + return if console._fleet is not None and path in _FLEET_STATIC: ctype, text = _FLEET_STATIC[path] self._send(200, ctype, text.encode("utf-8")) return + if path.startswith("/api/contacts/"): + self._handle_contacts_get(path) + return if path.startswith("/api/fleet/"): self._handle_fleet_get(path) return @@ -1553,6 +1574,8 @@ def _post(self) -> None: cap = _MAX_APP_BODY elif path in ("/api/chat/file", "/api/chat/profile"): cap = _MAX_CHAT_UPLOAD + elif path == "/api/contacts/import": + cap = _MAX_CONTACTS_IMPORT elif path == "/api/fleet/upload": cap = _MAX_FILE_UPLOAD elif path == "/api/fleet/keys": @@ -1798,6 +1821,14 @@ def _post(self) -> None: return self._handle_chat_post(path, _parse_json(body)) return + if path.startswith("/api/contacts/"): + if console._contacts is None: + self._json(404, {"error": "not found"}) + return + status, doc = console._contacts.handle( + path[len("/api/contacts/"):], _parse_json(body) or {}) + self._json(status, doc) + return if path.startswith("/api/fleet/"): if console._fleet is None: self._json(404, {"error": "not found"}) @@ -2605,6 +2636,34 @@ def _handle_provision(self, fleet, node: str, data) -> None: # -- built-in apps (install / enable) ----------------------------- + def _handle_contacts_get(self, path: str) -> None: + bridge = console._contacts + if bridge is None: + self._json(404, {"error": "not found"}) + return + if not self._authed(): + self._json(401, {"error": "unauthorized"}) + return + query = parse_qs(self.path.partition("?")[2]) + if path == "/api/contacts/state": + self._json(200, bridge.snapshot( + (query.get("book") or [""])[0], + have=(query.get("have") or [""])[0], + proto=console_feed.clean_proto((query.get("proto") or [0])[0]))) + elif path == "/api/contacts/people": + self._json(200, bridge.people( + (query.get("q") or [""])[0], wide=(query.get("wide") or ["1"])[0] != "0")) + elif path == "/api/contacts/export": + got = bridge.export((query.get("book") or [""])[0]) + if got is None: + self._json(404, {"error": "not found"}) + return + name, text = got + self._send(200, "text/vcard; charset=utf-8", text.encode("utf-8"), + [("Content-Disposition", f'attachment; filename="{name}"')]) + else: + self._json(404, {"error": "not found"}) + def _handle_chat_post(self, path: str, data) -> None: chat = console._chat data = data or {} @@ -2680,6 +2739,9 @@ def _handle_chat_post(self, path: str, data) -> None: self._json(200, {"results": chat.search_pseudo(pseudo)}) else: self._json(404, {"error": "not found"}) + except PermissionError as exc: + self._json(403, {"ok": False, "error": "permission", + "permission": str(exc)[:64]}) except Exception as exc: self._json(400, {"ok": False, "error": str(exc)[:200]}) diff --git a/tests/test_chat_web.py b/tests/test_chat_web.py index 3226181c..843891b8 100644 --- a/tests/test_chat_web.py +++ b/tests/test_chat_web.py @@ -244,7 +244,7 @@ async def test_the_sectioned_shape_names_every_section(self): assert got["proto"] == 2 and got["full"] is True assert set(got["revs"]) == { "peer", "me", "pseudo", "bio", "has_avatar", - "contacts", "known", "groups", "unread", "typing"} + "contacts", "contacts_access", "known", "groups", "unread", "typing"} assert set(got["sections"]) == set(got["revs"]) async def test_a_section_that_did_not_move_is_not_sent_again(self): @@ -310,3 +310,43 @@ async def test_the_route_reads_the_shape_the_page_asked_for(self): assert "contacts" not in held["sections"] finally: server.stop() + + +class TestVoiceMessages: + """A voice message is a file whose name says so. The name is the sender's, so + it is checked before anything is played, and a file that merely borrows the + name stays the plain file it is.""" + + @pytest.mark.parametrize("name,size", [ + ("voice-1500.webm", 4096), ("voice-1.ogg", 1), ("voice-600000.webm", 8 * 1024 * 1024), + ]) + def test_a_name_and_a_size_that_fit_are_a_voice_message(self, name, size): + from src.apps.chat_web import voice_length + assert voice_length(name, size) == int(name[6:].split(".")[0]) + + @pytest.mark.parametrize("name,size", [ + ("voice-0.webm", 10), ("voice-600001.webm", 10), + ("voice-1500.webm", 0), ("voice-1500.webm", 8 * 1024 * 1024 + 1), + ("voice-1500.mp3", 10), ("voice-1500.WEBM", 10), ("Voice-1500.webm", 10), + ("voice-15e2.webm", 10), ("voice--5.webm", 10), ("voice-1500.webm.exe", 10), + ("../voice-1500.webm", 10), ("voice-1500.webm\n", 10), ("voice-12345678.webm", 10), + ("voice- 5.webm", 10), ("xvoice-5.webm", 10), ("", 10), (None, 10), (5, 10), + ("voice-5.webm", None), ("voice-5.webm", "10"), ("voice-5.webm", True), + ]) + def test_anything_else_is_not(self, name, size): + from src.apps.chat_web import voice_length + assert voice_length(name, size) is None + + async def test_the_record_carries_the_length_the_name_gave(self): + bridge = ChatWebServer(ChatApp(StubClient()), host="127.0.0.1", port=0, + token=TOKEN, peer=PEER).bridge + rec = bridge._file_rec(b"\x01" * 8, "voice-4200.webm", 900, None) + assert rec["kind"] == "voice" and rec["duration_ms"] == 4200 + + async def test_a_file_that_borrows_the_name_is_a_plain_file(self): + bridge = ChatWebServer(ChatApp(StubClient()), host="127.0.0.1", port=0, + token=TOKEN, peer=PEER).bridge + huge = bridge._file_rec(b"\x01" * 8, "voice-4200.webm", 9 * 1024 * 1024, None) + assert huge["kind"] == "file" and "duration_ms" not in huge + picture = bridge._file_rec(b"\x01" * 8, "voice-4200.png", 100, None) + assert picture["kind"] == "image" diff --git a/tests/test_console_contacts.py b/tests/test_console_contacts.py new file mode 100644 index 00000000..1bd2caad --- /dev/null +++ b/tests/test_console_contacts.py @@ -0,0 +1,247 @@ +""" +The console's side of the Contacts app: its page, its routes, its refusals. + +A real server runs on an ephemeral loopback port and every HTTP call goes +through a worker thread (see ``test_console_fleet``). The app is built through an +:class:`AppHost` like the node does, so "the app is off" means what it means in +production: no page, no routes. +""" +import asyncio + +import pytest + +from src.app_registry import CONTACTS_APP_ID, AppHost, AppRegistry +from src.apps.contacts import ContactsApp +from src.apps.contacts_state import ContactsState +from src.apps.contacts_web import ContactsBridge +from src.node import MeshNode +from src.node_id import NodeID +from src.webconsole import WebConsole +from tests.conftest import make_manager +from tests.test_console_fleet import StubClient, _get, _post +from tests.test_webconsole import _login, PW + +ME = NodeID(bytes([0x11]) * 20) +ADA = "aa" * 20 +BOB = "bb" * 20 + + +async def _make(enabled=True): + node = MeshNode(transport_manager=make_manager()) + registry = AppRegistry(None) + registry.set_enabled("contacts", enabled) + host = AppHost(registry, app_storage=node.app_storage) + + async def build(): + app = ContactsApp(StubClient(), node_id=ME, state=ContactsState(me=ME.raw.hex())) + return app, ContactsBridge(app) + + host.register("contacts", build) + await host.apply() + console = WebConsole(node, host="127.0.0.1", port=0, use_tls=False, + password=PW, app_host=host) + console.start(loop=asyncio.get_running_loop()) + return node, console, host + + +@pytest.fixture +async def running(): + node, console, host = await _make() + _, token = await _login(console) + try: + yield console, token + finally: + console.stop() + await host.stop_all() + await node.stop() + + +async def _book(console, token): + status, _, _, body = await _get(console, "/api/contacts/state", token) + assert status == 200 + return body["book"], body + + +class TestThePage: + async def test_it_is_served_only_while_the_app_runs(self): + node, console, host = await _make(enabled=False) + try: + for path in ("/contacts", "/contacts.js", "/contacts.css"): + status, _, _, _ = await _get(console, path) + assert status == 404, path + _, token = await _login(console) + status, _, _, _ = await _get(console, "/api/contacts/state", token) + assert status == 404 + finally: + console.stop(); await host.stop_all(); await node.stop() + + async def test_the_markup_is_public_and_the_api_is_not(self, running): + console, token = running + for path in ("/contacts", "/contacts.js", "/contacts.css"): + status, _, body, _ = await _get(console, path) + assert status == 200 and body, path + for path in ("/api/contacts/state", "/api/contacts/people?q=ada", + "/api/contacts/export?book=" + "ab" * 16): + status, _, _, _ = await _get(console, path) + assert status == 401, path + status, _, _, _ = await _post(console, "/api/contacts/save", None, + {"book": "x", "name": "n"}) + assert status == 401 + + async def test_the_page_loads_the_catalogue_after_the_theme(self): + from src import webassets + html = webassets.CONTACTS_HTML + assert html.index('