Compare commits
150 changed files with 642 additions and 6589 deletions
|
|
@ -1,8 +1,3 @@
|
|||
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
|
||||
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
|
||||
'no-restricted-syntax'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
|
|
@ -67,21 +62,6 @@ module.exports = {
|
|||
"@typescript-eslint/no-shadow": "error"
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
...airbnbRestrictedSyntax,
|
||||
{
|
||||
selector: "CallExpression[callee.property.name='joinRoom']",
|
||||
message:
|
||||
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts'],
|
||||
rules: {
|
||||
|
|
|
|||
20
config.json
20
config.json
|
|
@ -1,13 +1,6 @@
|
|||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"gamecult.org",
|
||||
"converser.eu",
|
||||
"matrix.org",
|
||||
"mozilla.org",
|
||||
"unredacted.org",
|
||||
"xmr.se"
|
||||
],
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"allowCustomHomeservers": true,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
|
|
@ -27,14 +20,5 @@
|
|||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
},
|
||||
|
||||
"dings": {
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
# Mind feedback pages
|
||||
|
||||
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
|
||||
|
||||
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
|
||||
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
|
||||
Every number below is provisional.
|
||||
|
||||
**Schema owner.** The Huginn kinds, their fields, keys and refusals are specified in `huginn-feedback-kinds-proposal.md` (Eureka Self, 2026-11-07, in
|
||||
`/opt/commons/handoff`), adopted for this page by Void's Persona. Where this page names a field of those kinds it mirrors that proposal; if they differ, the proposal wins.
|
||||
This repo owns only the client: a typed interface mirroring those shapes, and fixtures.
|
||||
|
||||
Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its
|
||||
recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk
|
||||
(taken from the brief; no trace of it was found in this repo or in the Minds' state).
|
||||
|
||||
## 1. Placement
|
||||
|
||||
Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person
|
||||
is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title,
|
||||
so the section root stays a list of questions, not a wall of forms.
|
||||
|
||||
## 2. Authority
|
||||
|
||||
| Concern | Owner | The page's part |
|
||||
|---|---|---|
|
||||
| The question | a typed `question` document in the **owning Mind's own mind**, key `<campaign>:question:<label>` | a view of it; the page stores and edits nothing |
|
||||
| Which questions appear | the owning Mind, by a `feedback_listing` (the opt-in act) carrying `audience` and `close_grants`; un-listing is a `Withdrawn` resolution on it | shows only what has a listing in force |
|
||||
| An answer | its author. The **receiver (Bifrost) holds the text**; the Mind holds a **text-free `feedback_answer` record** | submits the answer to the receiver |
|
||||
| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has |
|
||||
| Status (open, answered, closed) | derived in the Mind from its documents (`closed` is a `Withdrawn` resolution on the question), never written | shows it |
|
||||
| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client |
|
||||
| Close | a `feedback_close_request` the owning Mind honours or declines; it never changes the question's status by itself | offers Close only to a grant holder; **the page writes no ruling** |
|
||||
| Hiding | the owning Mind alone, as a `feedback_hide`; unhide is a `Withdrawn` resolution on it | shows the labelled stub; hides nothing itself |
|
||||
| Erase | Bifrost's erase of an account; the receiver drops the text | shows "erased account" |
|
||||
|
||||
An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no
|
||||
ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything.
|
||||
|
||||
## 3. Receiver and carriers
|
||||
|
||||
- **Schema owner: `huginn-feedback-kinds-proposal.md`.** Four new Huginn kinds, all Eureka's: `feedback_listing` (audience, `close_grants` with no Guest
|
||||
grant, the opt-in act), `feedback_answer` (a text-free record), `feedback_close_request` and `feedback_hide`. Unhide, un-list and "request declined"
|
||||
are `resolution`s on those kinds. This page proposes no kinds.
|
||||
- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind`, weight and `time`,
|
||||
**stores the answer text**, delivers it, serves the projection the page reads, and **does the erase**. A Matrix sender is a different
|
||||
identity authority and never becomes `answerer`.
|
||||
- **Huginn is RUDP-only.** The client never talks to Huginn, and the receiver never writes to it: an admission declared for another instance is refused
|
||||
(`ForeignInstance`), so a receiver "admitting as Kiko" would be unauthenticated by construction.
|
||||
- **Delivery is by mouth.** The receiver delivers each answer or close request to the owning Mind's Persona through the mouth's pending/ack path (a new
|
||||
delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost). **The Persona admits the text-free record into her own mind** and acks only
|
||||
after it is admitted; a restart redelivers the same record id, which is `AlreadyAdmitted`. The text reaches her turn once, in the delivery, and is never
|
||||
admitted.
|
||||
- Dependencies that must ship first, in the proposal's order: Huginn readers (daemon, CLI, clients), then the Persona organ, then Bifrost's receiver.
|
||||
|
||||
## 4. Mock
|
||||
|
||||
```
|
||||
Kiko > Feedback [synced 2 min ago]
|
||||
--------------------------------------------------------------------------------------
|
||||
Open questions [ Open ] [ Settled ]
|
||||
|
||||
OPEN | asked 6 Nov 2026 09:12 | waiting 1 day
|
||||
Which source feeds audio_spectrum? [ Details v ]
|
||||
The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more")
|
||||
Waits on: "Overlay-wide effect budget" (open, links to that question)
|
||||
|
||||
( ) A Overlay's own audio only
|
||||
( ) B OBS captured stream Kiko recommends this
|
||||
( ) C Loopback of the mix, microphone excluded
|
||||
( ) Something else: [ up to 2000 characters ]
|
||||
Answers are visible to everyone who can see this question.
|
||||
[ Send answer ]
|
||||
[ Ask Kiko to close this question ] <- only if you hold the close grant on it
|
||||
|
||||
Answers (every one, oldest first)
|
||||
Meta operator B OBS captured stream 6 Nov 21:04
|
||||
Aqua Mind B OBS captured stream 6 Nov 21:40
|
||||
Nibu member C Loopback of the mix 7 Nov 08:12
|
||||
Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02
|
||||
a guest guest A Overlay's own audio only 7 Nov 09:15
|
||||
(answer hidden) guest Hidden by Kiko, 7 Nov, spam
|
||||
Earlier answers by the same person fold under their latest.
|
||||
--------------------------------------------------------------------------------------
|
||||
SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov
|
||||
```
|
||||
|
||||
- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer**
|
||||
and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such.
|
||||
- The recommendation is stated in words, not colour, and is never preselected.
|
||||
- Answering a settled question is not offered.
|
||||
|
||||
## 5. Data contract
|
||||
|
||||
The **Huginn shapes belong to the proposal** (section 2 there): `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`. The client
|
||||
declares a typed interface that mirrors them as the page's projection and imports nothing it cannot decode from them.
|
||||
|
||||
**Question projection** (`org.gamecult.feedback.question.v0`, read-only, served by Bifrost):
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `id` | `<campaign>:question:<label>`; the key carries the campaign, not the instance | the document key |
|
||||
| `owner` | the asking Mind's instance | the instance |
|
||||
| `title`, `body` | one line; prose up to 4000 bytes | `title`, `question` |
|
||||
| `options[]` | `{ id, text }`, 2 to 8; **the option id is the option's label** | `options` (`label`, `text`) |
|
||||
| `recommendation` | an option label | `recommended` |
|
||||
| `dependsOn[]` | `{ text, questionId? }`; an id only where the line cites one verbatim | `depends` (free text) |
|
||||
| `askedAt` | the question's receipt `committed_at` | the receipt; `asked_on` is only a date |
|
||||
| `audience`, `closeGrants[]` | `AccountHolders` or `MembersOnly`, no default; grantees are accounts or the kinds `Member` and `Mind`, **never `Guest`** | the in-force `feedback_listing` |
|
||||
| `status` | `open`, `answered` (with a `rulingId`) or `closed`, derived | the question's resolutions |
|
||||
|
||||
**Answer row** (`org.gamecult.feedback.answer.v0`, assembled by the receiver from its stored record and the Mind's text-free `feedback_answer`):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `recordId` | `<question-label>.a<N>`, N the receiver's per-question sequence |
|
||||
| `answerer` | the **Bifrost account id** (stable; the authenticator handle is cleared on erase); never a display name, never client-supplied |
|
||||
| `kind` | `Guest`, `User`, `Patron`, `Member` or `Mind`, read from Bifrost's account state **at answer time**; the session carries identity, never role |
|
||||
| `weightPermille` | stamped 0 for a guest, 1000 otherwise; **the page sums nothing** |
|
||||
| `choice` / `text` | an option label, free text up to 2000 characters, or both; at least one. Text exists only in the receiver's store and the projection, never in a mind |
|
||||
| `time` | when the receiver accepted it |
|
||||
|
||||
Invariants: the receiver takes `answerer`, `kind` and weight from the verified session and account state, never from the request body; an unknown option
|
||||
label is refused; the latest record per account is its standing answer, derived at read time; display names are looked up for display and are never keys. An
|
||||
account may answer only while its status is `active`, read at answer time. A guest cannot request close, and a guest grant cannot be written. Answers to a
|
||||
settled question are kept. Nothing is edited or deleted, except the receiver's redaction on erase (section 7).
|
||||
|
||||
## 6. Flow
|
||||
|
||||
```
|
||||
owning Mind's mind --(Bifrost projection)--> page page --(answer, Heimdall session)--> receiver (Bifrost; holds the text)
|
||||
receiver --(mouth delivery)--> owning Mind's Persona --(admits text-free feedback_answer)--> her mind
|
||||
the Mind decides and records the ruling herself --> status derives --> projection updates --> page moves it to Settled
|
||||
```
|
||||
|
||||
1. **View.** The page shows the projection of the questions with a `feedback_listing` in force, filtered by the viewer's kind against `audience`. The mind
|
||||
stays the truth; the projection is rebuilt, never hand-edited, and republished on a heartbeat so freshness is visible.
|
||||
2. **Answer.** The page submits with the person's Heimdall session; the receiver stamps, stores the text and appends the record.
|
||||
3. **Return.** The receiver delivers by mouth; the Persona admits the text-free record and reads the text once. Each arrives as **quoted, attributed data**
|
||||
(record id, author, kind, time), never in an instruction position: text from a guest, user or patron is untrusted, and she records a ruling from it, never
|
||||
an action. The receiver does no keyword screening. She does not quote answer text into memory or a ruling; she cites the record id.
|
||||
4. **Decide.** She records the ruling in her mind. The next projection carries `answered` and the ruling link.
|
||||
5. **Close.** A grant holder's request is delivered as a `feedback_close_request`. The Mind honours it by resolving the question, or declines with a
|
||||
`Recorded` resolution on the request; the request never changes the status itself. The page shows who asked and never how many.
|
||||
|
||||
## 7. Guests, erasure and hiding
|
||||
|
||||
- **Guests may answer** and see what the Mind opted in for their audience; they may not close (refused at the receiver and again by Huginn, and no listing can
|
||||
name a Guest grantee). A guest answer carries weight 0 by construction and is tallied nowhere. Guests see display name and kind only, never a Matrix id,
|
||||
provider handle or GitHub login.
|
||||
- **Erase redacts.** Records are append-only and are **never edited, but may be redacted**. A mind cannot redact: every admission's receipt keeps its exact
|
||||
payload bytes, which is why the text never enters one. When Bifrost erases an account, **the receiver drops that account's answer text and name** and keeps record
|
||||
id, kind and time only. **A content hash is not kept**: an unsalted hash would confirm a guessed text. The author shows as "erased account". The receiver delivers an erase notice and the Persona admits a `feedback_hide` with
|
||||
reason `ErasedAuthor`, the Mind's acknowledgement rather than a moderation call. The honest residue: a Mind's own memory or ruling may already quote an
|
||||
answer, and Huginn cannot redact it; the rule that she cites record ids, never text, is a prompt, not an invariant.
|
||||
- **Only the owning Mind hides**, as a `feedback_hide`: target (an answer or a question), reason from the closed set (`PrivateMatter`, `Abuse`, `Spam`,
|
||||
`OffTopic`, `AuthorRequest`, `ErasedAuthor`) and a line of at most 200 bytes, with its admission receipt as the audit trail. Unhide is a `Withdrawn`
|
||||
resolution on the hide. No count of reports ever hides anything and there is no auto-hide threshold; the operator orders a hide in conversation and the
|
||||
Mind records it.
|
||||
- **Anyone with an account may report** an answer; the receiver delivers it to the Mind as input. The page shows nothing of it and never a count. An author
|
||||
reporting their own answer uses `AuthorRequest` and the Mind honours it.
|
||||
- **What a hidden item shows.** A hidden answer keeps its row (author, kind, time, "Hidden by <Mind>, <date>, <reason>") with its text withheld for every
|
||||
reason. A hidden question shows a stub with its title, body, options and answers withheld; taking a question off the page is a `Withdrawn` resolution on its
|
||||
listing. The answer box says answers are visible to everyone who can see the question.
|
||||
- **Private matter** is never listed. An answer carrying someone's private matter is hidden with `PrivateMatter`.
|
||||
- Guest answers are collected for nothing but delivery to the owning Mind.
|
||||
- **No content hash anywhere.** No answer, close-request or redaction shape in this page carries one. The proposal's `feedback_answer` lists a `content_hash`
|
||||
field; per Heimdall it must go, and Eureka owns that change.
|
||||
|
||||
## 7a. Receiver: credential, store, names
|
||||
|
||||
Source: Bifrost PR #10, `docs/feedback-receiver.md` (design, nothing implemented).
|
||||
|
||||
- **Credential: route 2.** Ratatoskr presents the **OIDC access token Bifrost already issues to chat**, and Bifrost verifies it itself. The client holds no new
|
||||
secret and needs no Heimdall token. The account is the token's subject, `UserAccount.Id`. **The claims check is still open**: which claims the token carries
|
||||
and which Bifrost must verify for this endpoint have not been read.
|
||||
- **Store: a CultCache file** (Eureka's pick), owned and written only by the receiver. **Backups need a stated purge or expiry before any deploy**, so that
|
||||
erased text does not survive in them; the number is left to Eureka and Heimdall.
|
||||
- **Display names.** The receiver must not reuse `CurrentBifrostActor.DisplayName`, which falls back to the GitHub login and then to "Guest". It stores the
|
||||
name at answer time from the member nickname or account display name only, and "an account" when both are empty. Never a provider handle, GitHub login,
|
||||
Heimdall id or Matrix localpart.
|
||||
- **No guest exists until Bifrost PR #9 lands.** On main an active membership is a member, an authenticated or pending one a user, and nobody is a guest, so the
|
||||
guest rules in this page have no account to apply to until then. An answer needs an active account, read at answer time.
|
||||
|
||||
## 8. States
|
||||
|
||||
- **Empty:** "Nothing is waiting on you from Kiko", when it last synced, a link to Settled. Never blank.
|
||||
- **No answers:** "No answers yet", with the question's age.
|
||||
- **Stale projection:** past two heartbeats (provisionally 30 minutes) the header reads "may be out of date"; answering stays possible. A projection that
|
||||
never arrived says "Kiko's questions have not been published yet", not "no questions".
|
||||
- **Long open:** past 14 days (provisional) shows its age; it is not demoted or hidden.
|
||||
- **Blocked:** shows the dependency chip with that question's status; answering stays allowed.
|
||||
- **Settled while answering:** the answer is kept for the Mind and the page says it arrived after the question settled.
|
||||
- **Signed out, expired, or not active:** answers are shown, the form asks to sign in or says why it cannot; nothing typed is lost.
|
||||
- **Send failed:** the text stays in the field with the reason and a retry.
|
||||
|
||||
## 9. Build order
|
||||
|
||||
1. The client's typed interface, mirroring the proposal's shapes and the projections in section 5, and **fixtures** for every state in section 8 plus the
|
||||
guest, erased and hidden rows. The page renders from fixtures; no network.
|
||||
2. The proposal's kinds and migration (Eureka: readers first, then the Persona organ), and the Bifrost account publisher, receiver and mouth delivery
|
||||
(Heimdall). The page switches from fixtures to the projection.
|
||||
|
||||
## 10. Accessibility
|
||||
|
||||
- Semantic structure: `main` with a heading; each question an `article` with its title as a heading; options a `fieldset`/`legend` radio group; the
|
||||
free-text field labelled, with a character counter announced politely. The phone fold is a button with `aria-expanded`.
|
||||
- No meaning by colour alone: status, recommendation, kind, hidden and close-grant are text.
|
||||
- Keyboard: everything reachable in a sensible order; after Send, focus goes to a confirmation naming the question and the answer.
|
||||
- New answers and status changes arrive in an `aria-live="polite"` region, never as a focus steal.
|
||||
- Targets at least 44 px; reduced motion respected; contrast per theme.
|
||||
- Names and answers are user text: `dir="auto"`, control and bidi characters stripped, long words wrap.
|
||||
|
||||
## 11. Still open
|
||||
|
||||
1. **Emergency withhold.** If the owning Mind is away and an answer shows something illegal or dangerous, may the host withhold the text at the receiver?
|
||||
Heimdall leans no: it is a second owner. Meta's fork.
|
||||
2. **How a Mind authenticates to answer** (kind `Mind`): Bifrost has no `Mind` role and the account publisher `gamecult.bifrost.account.v1` does not exist yet.
|
||||
3. **Erase is bounded.** Text a Mind has already quoted into her own documents cannot be redacted; absolute erase would mean the text never enters her turn.
|
||||
4. **`AccountId` grammar**, pinned to Bifrost's account id shape when that publisher lands.
|
||||
5. **Receiver claims.** The OIDC access token's claims for this endpoint (section 7a) are unread.
|
||||
6. **Reader ordering.** Whether an old Huginn reader skips or fails on a mind holding a kind it does not know is unverified (proposal section 6.4); it decides
|
||||
when the first Mind may list a question.
|
||||
|
||||
## 12. The desk: the page as the Fork Desk's descendant
|
||||
|
||||
Added 2026-11-10 by Void from Meta's brief and the original Fork Desk (`/opt/commons/handoff/fork-desk-original/`, Nibu's newest copy; working note `/opt/commons/handoff/ratatoskr-desk-spec-20261110.md`, section 11). The feedback page **is** the desk: one surface, not two. Design only.
|
||||
|
||||
**Keep from the original.**
|
||||
- Questions stream as rows (not one at a time): title, body as context, options with the recommended one marked **in words**, the `depends` line, a free-text note carrying the person's words verbatim, and a "recorded as rulings" list of settled questions with their ruling id. A pick is optional; the note can answer alone.
|
||||
- A **booth Persona**: the owning Mind's pose beside the rows on wide screens, a small fixed corner image on phones, reacting by mood: no forks (neutral), all answered (triumph), none answered (thinking), partway (determined, "N to go"), a pick equal to the recommendation (happy), a different pick (surprised, "a line on why helps"), a cleared pick (note), a failed send (concerned), a new fork arriving (surprised). The rules are a pure function of the forks and the last event (`deskMood`), tested exactly. Poses come from the shared cast art; a Mind without art shows no booth rather than another's face.
|
||||
- Answers save as the person acts, and she can close the page and come back.
|
||||
|
||||
**What changes.**
|
||||
- The row is the section 5 question projection. The page changes nothing about who owns a question, a ruling or a status (section 2).
|
||||
- Phones: each row's options and note fold behind its title (section 1); the entry point is a **"Desk · N open"** line on the Mind's group header in the navigator, hidden at zero. Chromeless: hairlines are allowed, panels, cards and pills are not.
|
||||
- **Answer rule and wire.** The answer rule (the body the client may send, `options_hash`, the counting order, retraction, the stored `desk_answer`) is owned by Heimdall's rule file `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`; the wire is owned by CultLib #72. This section restates neither. The client's part: it sends only what the rule's body allows (never `answerer` or authority), recomputes `options_hash` from what it rendered and refuses to send on a mismatch with the projection's.
|
||||
- **A pick sends at once** (one tap, one record; the latest per account stands, section 5). **The note autosaves as a local draft** and is sent on blur or an explicit Send, never per keystroke. "Saved" says which: sent, or kept on this device.
|
||||
- **Clear pick is a retraction**, a revision of the answer under the retraction rule in Heimdall's rule file (section 1.3a there); the one exception to section 5's "at least one of choice or text" is that rule's, not this page's. Until the receiver implements it, Clear pick clears only an unsent draft.
|
||||
- "Self leans" and a provisional flag exist in the original; the question has no field for either, so the desk shows neither until one exists.
|
||||
|
||||
**Order.** Section 9 step 1 stands: typed interface and fixtures first. The desk's first slice is read-only (note field disabled with a plain sentence that answering opens with sign-in), and answering waits on Bifrost sign-in reaching chat, the wire (CultLib #72) and the receiver.
|
||||
|
|
@ -2,17 +2,12 @@
|
|||
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||
const fs = require('fs');
|
||||
const fails = [];
|
||||
let asserted = 0;
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
asserted += 1;
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
|
||||
exports.done = () => {
|
||||
if (fails.length) return 4;
|
||||
return asserted === 0 ? 5 : 0;
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed. */
|
||||
exports.done = () => (fails.length ? 4 : 0);
|
||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
// @vitest-environment node
|
||||
import { createRequire } from 'module';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A drive that asserts nothing proves nothing: its exit code must say so.
|
||||
const fresh = () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const path = require.resolve('./check.cjs');
|
||||
delete require.cache[path];
|
||||
return require('./check.cjs') as {
|
||||
expect: (label: string, ok: boolean, detail?: string) => void;
|
||||
done: () => number;
|
||||
};
|
||||
};
|
||||
|
||||
describe('estate drive exit code', () => {
|
||||
it('is 5 when no assertion ran', () => {
|
||||
expect(fresh().done()).toBe(5);
|
||||
});
|
||||
|
||||
it('is 0 when assertions ran and all held', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
expect(c.done()).toBe(0);
|
||||
});
|
||||
|
||||
it('is 4 when any assertion failed', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
c.expect('breaks', false, 'detail');
|
||||
expect(c.done()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +1,12 @@
|
|||
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
|
||||
// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from
|
||||
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
|
||||
// for a driver to read. Not shipped.
|
||||
// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped.
|
||||
import { startBackgroundScene } from '../../src/app/features/scene';
|
||||
import type { SceneTier } from '../../src/app/features/scene';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const tier = (q.get('tier') ?? 'full') as SceneTier;
|
||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
|
||||
w.bakes = [];
|
||||
w.longTasks = [];
|
||||
try {
|
||||
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
|
||||
entryTypes: ['longtask'],
|
||||
});
|
||||
} catch {
|
||||
// No long-task entries in this browser.
|
||||
}
|
||||
const t0 = performance.now();
|
||||
startBackgroundScene({ tier }, {}, {
|
||||
canvas,
|
||||
onBake: (b: unknown) => w.bakes.push(b),
|
||||
} as never).then((scene) => {
|
||||
w.firstViewMs = performance.now() - t0;
|
||||
w.scene = scene;
|
||||
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
|
||||
(window as unknown as { scene: unknown }).scene = scene;
|
||||
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# Dings
|
||||
|
||||
Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`.
|
||||
Aqua renders the clips; nothing here is synthesised in the browser.
|
||||
|
||||
dings/manifest.json
|
||||
dings/senders/<mxid-localpart>/<motif>.ogg
|
||||
dings/neutral/<motif>.ogg
|
||||
|
||||
`manifest.json` is `{ "neutral": { <motif>: "<path>" }, "senders": { <localpart>: { <motif>: "<path>" } } }`
|
||||
with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`.
|
||||
A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps
|
||||
its stock notification sounds; once one exists, a motif with no clip is silent.
|
||||
|
||||
The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66),
|
||||
Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the
|
||||
client config sets `"dings": { "enabled": true }` (default off, as in `config.json`).
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
{
|
||||
"neutral": {
|
||||
"mention": "neutral/mention.ogg",
|
||||
"dm": "neutral/dm.ogg",
|
||||
"message": "neutral/message.ogg",
|
||||
"invite": "neutral/invite.ogg"
|
||||
},
|
||||
"senders": {
|
||||
"aqua": {
|
||||
"mention": "senders/aqua/mention.ogg",
|
||||
"dm": "senders/aqua/dm.ogg",
|
||||
"message": "senders/aqua/message.ogg",
|
||||
"invite": "senders/aqua/invite.ogg"
|
||||
},
|
||||
"eureka": {
|
||||
"mention": "senders/eureka/mention.ogg",
|
||||
"dm": "senders/eureka/dm.ogg",
|
||||
"message": "senders/eureka/message.ogg",
|
||||
"invite": "senders/eureka/invite.ogg"
|
||||
},
|
||||
"heimdall": {
|
||||
"mention": "senders/heimdall/mention.ogg",
|
||||
"dm": "senders/heimdall/dm.ogg",
|
||||
"message": "senders/heimdall/message.ogg",
|
||||
"invite": "senders/heimdall/invite.ogg"
|
||||
},
|
||||
"kiko": {
|
||||
"mention": "senders/kiko/mention.ogg",
|
||||
"dm": "senders/kiko/dm.ogg",
|
||||
"message": "senders/kiko/message.ogg",
|
||||
"invite": "senders/kiko/invite.ogg"
|
||||
},
|
||||
"libby": {
|
||||
"mention": "senders/libby/mention.ogg",
|
||||
"dm": "senders/libby/dm.ogg",
|
||||
"message": "senders/libby/message.ogg",
|
||||
"invite": "senders/libby/invite.ogg"
|
||||
},
|
||||
"nibu": {
|
||||
"mention": "senders/nibu/mention.ogg",
|
||||
"dm": "senders/nibu/dm.ogg",
|
||||
"message": "senders/nibu/message.ogg",
|
||||
"invite": "senders/nibu/invite.ogg"
|
||||
},
|
||||
"void": {
|
||||
"mention": "senders/void/mention.ogg",
|
||||
"dm": "senders/void/dm.ogg",
|
||||
"message": "senders/void/message.ogg",
|
||||
"invite": "senders/void/invite.ogg"
|
||||
},
|
||||
"weksa": {
|
||||
"mention": "senders/weksa/mention.ogg",
|
||||
"dm": "senders/weksa/dm.ogg",
|
||||
"message": "senders/weksa/message.ogg",
|
||||
"invite": "senders/weksa/invite.ogg"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,9 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build this checkout as the offline demo for the standing dev preview: base /void/rat-dev/, the pretend
|
||||
# homeserver, scene tier $TIER (default full). Output: ./dist, ready for the shared publisher
|
||||
# (gamecult-ops scripts/publish-mind-preview.sh dist rat-dev), which owns publishing. Publishes nothing.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
node scripts/sync-poses.mjs >/dev/null
|
||||
VITE_PREVIEW_DEMO=1 CINNY_BASE=/void/rat-dev/ NODE_OPTIONS=--max_old_space_size=4096 nice -n 10 npx vite build
|
||||
node scripts/preview-config.js "${TIER:-full}" --demo
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadServerConfigs } from './ServerConfigsLoader';
|
||||
|
||||
const server = (authMetadata: () => Promise<unknown>) =>
|
||||
({
|
||||
getCapabilities: async () => ({}),
|
||||
getMediaConfig: async () => ({ 'm.upload.size': 1 }),
|
||||
getAuthMetadata: authMetadata,
|
||||
} as unknown as Parameters<typeof loadServerConfigs>[0]);
|
||||
|
||||
describe('loadServerConfigs', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const configs = await loadServerConfigs(
|
||||
server(async () => {
|
||||
throw new Error('M_UNRECOGNIZED');
|
||||
})
|
||||
);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(configs.authMetadata).toBeUndefined();
|
||||
expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 });
|
||||
});
|
||||
|
||||
it('still logs and drops auth metadata that came back malformed', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' })));
|
||||
expect(error).toHaveBeenCalled();
|
||||
expect(configs.authMetadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,5 @@
|
|||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Capabilities,
|
||||
MatrixClient,
|
||||
validateAuthMetadata,
|
||||
ValidatedAuthMetadata,
|
||||
} from 'matrix-js-sdk';
|
||||
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||
|
|
@ -19,42 +14,35 @@ export type ServerConfigs = {
|
|||
type ServerConfigsLoaderProps = {
|
||||
children: (configs: ServerConfigs) => ReactNode;
|
||||
};
|
||||
/**
|
||||
* The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend
|
||||
* server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back
|
||||
* and fails validation is logged and dropped.
|
||||
*/
|
||||
export async function loadServerConfigs(
|
||||
mx: Pick<MatrixClient, 'getCapabilities' | 'getMediaConfig' | 'getAuthMetadata'>
|
||||
): Promise<ServerConfigs> {
|
||||
const result = await Promise.allSettled([
|
||||
mx.getCapabilities(),
|
||||
mx.getMediaConfig(),
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
|
||||
const capabilities = promiseFulfilledResult(result[0]);
|
||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
if (authMetadata) {
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata };
|
||||
}
|
||||
|
||||
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const fallbackConfigs = useMemo(() => ({}), []);
|
||||
|
||||
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
|
||||
useCallback(() => loadServerConfigs(mx), [mx])
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([
|
||||
mx.getCapabilities(),
|
||||
mx.getMediaConfig(),
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
|
||||
const capabilities = promiseFulfilledResult(result[0]);
|
||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
mediaConfig,
|
||||
authMetadata: validatedAuthMetadata,
|
||||
};
|
||||
}, [mx])
|
||||
);
|
||||
|
||||
const configs: ServerConfigs =
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ export const Reply = style({
|
|||
});
|
||||
|
||||
export const ReplyContent = style({
|
||||
minWidth: 0,
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
selectors: {
|
||||
|
|
@ -40,19 +39,3 @@ export const ReplyContent = style({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReplyText = style({
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
export const ReplyWrap = style({
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,14 +101,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={css.ReplyWrap}
|
||||
direction="Row"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||
{threadRootId && (
|
||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||
)}
|
||||
|
|
@ -126,7 +119,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||
onClick={onClick}
|
||||
>
|
||||
{replyEvent !== undefined ? (
|
||||
<Text size="T300" className={css.ReplyText}>
|
||||
<Text size="T300" truncate>
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
</Text>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ type BubbleLayoutProps = {
|
|||
hideBubble?: boolean;
|
||||
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
|
||||
own?: boolean;
|
||||
/** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */
|
||||
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
|
||||
pose?: ReactNode;
|
||||
/** On phones the portrait floats on the other side. */
|
||||
alt?: boolean;
|
||||
/** Delay in ms before the first-appearance animation; undefined plays none. */
|
||||
enterDelay?: number;
|
||||
before?: ReactNode;
|
||||
|
|
@ -39,7 +41,7 @@ type BubbleLayoutProps = {
|
|||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => {
|
||||
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
|
||||
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
|
||||
const poseRow = pose !== undefined;
|
||||
const arrow = !!before && !poseRow && !own;
|
||||
|
|
@ -48,7 +50,12 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
<Box
|
||||
gap="300"
|
||||
{...props}
|
||||
className={classNames(own && css.BubbleRowOwn, props.className)}
|
||||
className={classNames(
|
||||
poseRow && css.BubbleRowPose,
|
||||
pose && css.BubbleRowPortrait,
|
||||
own && css.BubbleRowOwn,
|
||||
props.className
|
||||
)}
|
||||
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
|
||||
ref={ref}
|
||||
>
|
||||
|
|
@ -60,9 +67,19 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
<Box
|
||||
grow={own ? 'No' : 'Yes'}
|
||||
direction="Column"
|
||||
className={classNames(own && css.BubbleColumnOwn)}
|
||||
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
|
||||
>
|
||||
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
|
||||
{header && (
|
||||
<div
|
||||
className={classNames(
|
||||
pose && css.BubbleHeaderPose,
|
||||
pose && alt && css.BubbleHeaderPoseAlt,
|
||||
own && css.BubbleHeaderOwn
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{hideBubble ? (
|
||||
children
|
||||
) : (
|
||||
|
|
@ -72,7 +89,6 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
css.BubbleContent,
|
||||
arrow && css.BubbleContentArrowLeft,
|
||||
poseRow && css.BubbleContentPose,
|
||||
pose && css.BubbleContentPortrait,
|
||||
own && css.BubbleContentOwn,
|
||||
entering && css.EnterBubble
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize';
|
||||
|
||||
export const StickySection = style({
|
||||
position: 'sticky',
|
||||
|
|
@ -153,21 +152,39 @@ export const BubbleContentArrowLeft = style({
|
|||
borderTopLeftRadius: 0,
|
||||
});
|
||||
|
||||
// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's
|
||||
// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the
|
||||
// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble
|
||||
// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts.
|
||||
// Displayed width of the whole sprite image; the crop fractions are multiples of it.
|
||||
export const portraitWidth = createVar();
|
||||
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
|
||||
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
|
||||
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
|
||||
const PHONE = 'screen and (max-width: 700px)';
|
||||
const PORTRAIT = toRem(190);
|
||||
const PHONE_PORTRAIT = 'min(38vw, 160px)';
|
||||
const PHONE_TEXT_FLOOR = toRem(170);
|
||||
const BUBBLE_PAD = toRem(12);
|
||||
const PORTRAIT_OVERHANG = 58;
|
||||
const PORTRAIT = toRem(124);
|
||||
const POSE_GUTTER = toRem(14);
|
||||
const BUBBLE_CORNER = toRem(6);
|
||||
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
|
||||
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
|
||||
|
||||
export const BubbleRowPose = style({
|
||||
position: 'relative',
|
||||
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A row that shows a portrait: room for it to hang above the bubble on phones.
|
||||
export const BubbleRowPortrait = style({
|
||||
minHeight: PORTRAIT,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
minHeight: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleRowOwn = style({
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
|
|
@ -181,29 +198,64 @@ export const BubbleHeaderOwn = style({
|
|||
alignSelf: 'flex-end',
|
||||
});
|
||||
|
||||
// The message body is a flex column, which forms its own block formatting context and would sit
|
||||
// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and
|
||||
// then run the bubble's full width below it.
|
||||
export const BubbleContentPose = style({
|
||||
display: 'flow-root',
|
||||
overflow: 'clip',
|
||||
padding: BUBBLE_PAD,
|
||||
});
|
||||
|
||||
// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside
|
||||
// it collapses to a strip.
|
||||
export const BubbleContentPortrait = style({
|
||||
vars: { [portraitWidth]: PORTRAIT },
|
||||
minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`,
|
||||
// Phone: name and time are one plain line in flow above the bubble, on the side opposite the
|
||||
// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name
|
||||
// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by
|
||||
// the column: a short bubble's column is narrower than its own name, and a percentage cap there
|
||||
// truncated names to one letter.
|
||||
export const BubbleHeaderPose = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
vars: { [portraitWidth]: PHONE_PORTRAIT },
|
||||
alignSelf: 'flex-end',
|
||||
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
|
||||
// the portrait, so the overhang needs no margin of its own.
|
||||
minHeight: toRem(PORTRAIT_OVERHANG),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
margin: `0 ${toRem(14)} ${toRem(2)}`,
|
||||
width: 'max-content',
|
||||
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
|
||||
textAlign: 'right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${BubbleContentPose} > div`, {
|
||||
display: 'block',
|
||||
export const BubbleHeaderPoseAlt = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
alignSelf: 'flex-start',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
|
||||
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
marginTop: toRem(PORTRAIT_OVERHANG),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleColumnPose = style({
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const BubbleContentPose = style({
|
||||
borderRadius: config.radii.R500,
|
||||
borderTopLeftRadius: BUBBLE_CORNER,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
display: 'flow-root',
|
||||
// A bubble with a portrait is at least portrait + a readable column wide, or the text
|
||||
// beside the portrait collapses to a strip.
|
||||
minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`,
|
||||
borderTopLeftRadius: config.radii.R500,
|
||||
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleContentOwn = style({
|
||||
|
|
@ -214,37 +266,51 @@ export const BubbleContentOwn = style({
|
|||
borderTopRightRadius: BUBBLE_CORNER,
|
||||
});
|
||||
|
||||
// The button is the visible part of the sprite (the part inside the bubble's corner); the image
|
||||
// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from
|
||||
// nestView; until the art's size is known the button is simply the whole image.
|
||||
export const PoseButton = style({
|
||||
float: 'left',
|
||||
width: `var(--pose-bw, ${portraitWidth})`,
|
||||
height: 'var(--pose-bh, auto)',
|
||||
// Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop.
|
||||
margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
|
||||
width: PORTRAIT,
|
||||
height: PORTRAIT,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
overflow: 'hidden',
|
||||
shapeMargin: toRem(7),
|
||||
transformOrigin: '50% 90%',
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
position: 'static',
|
||||
float: 'left',
|
||||
width: PHONE_PORTRAIT,
|
||||
height: 'auto',
|
||||
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
|
||||
shapeMargin: toRem(7),
|
||||
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
maskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PoseButtonAlt = style({
|
||||
float: 'right',
|
||||
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
float: 'right',
|
||||
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PosePortrait = style({
|
||||
display: 'block',
|
||||
width: portraitWidth,
|
||||
maxWidth: 'none',
|
||||
height: 'auto',
|
||||
marginLeft: 'var(--pose-ix, 0)',
|
||||
marginTop: 'var(--pose-iy, 0)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'top',
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
height: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pop = keyframes({
|
||||
|
|
@ -284,9 +350,6 @@ export const BubbleLeftArrow = style({
|
|||
zIndex: 1,
|
||||
});
|
||||
|
||||
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
||||
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
|
||||
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
|
|
@ -294,9 +357,6 @@ export const Username = style({
|
|||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
'@media': {
|
||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
|
||||
},
|
||||
},
|
||||
'button&:hover, button&:focus-visible': {
|
||||
textDecoration: 'underline',
|
||||
|
|
@ -335,12 +395,61 @@ export const MessageTextBody = recipe({
|
|||
|
||||
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
||||
|
||||
// The message content is a flex column, which forms its own block formatting context and so sits
|
||||
// whole beside the floated portrait: the text stays as narrow as the gap beside it for the
|
||||
// bubble's full height. As a plain block its lines wrap around the silhouette and then run the
|
||||
// bubble's full width below it.
|
||||
globalStyle(`${BubbleContentPose} > div`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The name wraps instead of ellipsising: the header already caps its own width, and Username's own
|
||||
// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…".
|
||||
globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'visible',
|
||||
textOverflow: 'clip',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A name too long for one line takes its own lines and the time drops below it, instead of the
|
||||
// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The
|
||||
// row hugs the side the header sits on, so a dropped time stays under the name's edge.
|
||||
globalStyle(`${BubbleHeaderPose} > div`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${BubbleHeaderPoseAlt} > div`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so
|
||||
// beside a left portrait the number lands under the art. Inside, it is a word on the first line
|
||||
// and wraps around the silhouette like the rest.
|
||||
globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, {
|
||||
listStylePosition: 'inside',
|
||||
paddingLeft: 0,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
listStylePosition: 'inside',
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in
|
||||
|
|
|
|||
|
|
@ -1,126 +1,53 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from '../layout/layout.css';
|
||||
import { poseSrc, safeBox } from './poses';
|
||||
import { alphaBounds, nestView, type Box } from './nest';
|
||||
import { poseSrc } from './poses';
|
||||
import { silhouette } from './silhouette';
|
||||
|
||||
type PoseAvatarProps = {
|
||||
mind: string;
|
||||
pose: string;
|
||||
userId: string;
|
||||
/** Nest in the bubble's top-right corner instead of the top-left. */
|
||||
/** Float on the right on phones. */
|
||||
alt?: boolean;
|
||||
/** Play the first-appearance pop. */
|
||||
enter?: boolean;
|
||||
/** Without it the portrait is a plain image: no button, no tab stop. */
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
// Safe boxes derived from art's alpha, by source; the art is traced once.
|
||||
const traced = new Map<string, { box: Box; size: [number, number] }>();
|
||||
const trace = (img: HTMLImageElement, src: string) => {
|
||||
const hit = traced.get(src);
|
||||
if (hit) return hit;
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
if (!ctx) return undefined;
|
||||
ctx.canvas.width = w;
|
||||
ctx.canvas.height = h;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h);
|
||||
if (!box) return undefined;
|
||||
const found = { box, size: [w, h] as [number, number] };
|
||||
traced.set(src, found);
|
||||
return found;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
|
||||
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [shape, setShape] = useState<string>();
|
||||
const measured = safeBox(mind, pose);
|
||||
const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>();
|
||||
const art = measured ?? (derived?.size ? derived : undefined);
|
||||
// Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is
|
||||
// already tight to the art, so the bubble may take all of the transparent margin.
|
||||
const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1);
|
||||
|
||||
// Art with no measured box: find its extent when it loads.
|
||||
useEffect(() => {
|
||||
setDerived(undefined);
|
||||
const img = imgRef.current;
|
||||
if (!img || measured) return undefined;
|
||||
const find = () => {
|
||||
if (!img.complete || img.naturalWidth === 0) return;
|
||||
try {
|
||||
setDerived(trace(img, src));
|
||||
} catch {
|
||||
setDerived(undefined);
|
||||
}
|
||||
};
|
||||
find();
|
||||
img.addEventListener('load', find);
|
||||
return () => img.removeEventListener('load', find);
|
||||
}, [src, measured]);
|
||||
|
||||
// The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its
|
||||
// crop is known, and when its width changes with the viewport.
|
||||
// On phones the bubble text wraps around the portrait's silhouette. Retraced when the art
|
||||
// loads and when the portrait's width changes with the viewport.
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return undefined;
|
||||
const retrace = () => {
|
||||
const trace = () => {
|
||||
if (!img.complete || img.naturalWidth === 0) return;
|
||||
try {
|
||||
const box = img.parentElement;
|
||||
const style = getComputedStyle(img);
|
||||
setShape(
|
||||
silhouette(
|
||||
img,
|
||||
!alt,
|
||||
box
|
||||
? {
|
||||
x: 0 - parseFloat(style.marginLeft || '0'),
|
||||
y: 0 - parseFloat(style.marginTop || '0'),
|
||||
width: box.clientWidth,
|
||||
height: box.clientHeight,
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
setShape(silhouette(img, !alt));
|
||||
} catch {
|
||||
setShape(undefined);
|
||||
}
|
||||
};
|
||||
retrace();
|
||||
img.addEventListener('load', retrace);
|
||||
const resize = new ResizeObserver(retrace);
|
||||
trace();
|
||||
img.addEventListener('load', trace);
|
||||
const resize = new ResizeObserver(trace);
|
||||
resize.observe(img);
|
||||
return () => {
|
||||
img.removeEventListener('load', retrace);
|
||||
img.removeEventListener('load', trace);
|
||||
resize.disconnect();
|
||||
};
|
||||
}, [src, alt, view?.x, view?.y, view?.width, view?.height]);
|
||||
}, [src, alt]);
|
||||
|
||||
// Crop fractions are of the image's width, so one set serves every displayed size.
|
||||
const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`;
|
||||
const cropStyle =
|
||||
art && view
|
||||
? ({
|
||||
'--pose-bw': frac(view.width),
|
||||
'--pose-bh': frac(view.height),
|
||||
'--pose-ix': frac(-view.x),
|
||||
'--pose-iy': frac(-view.y),
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const Tag = onClick ? 'button' : 'span';
|
||||
return (
|
||||
<Tag
|
||||
type={onClick ? 'button' : undefined}
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
|
||||
style={{ ...cropStyle, shapeOutside: shape }}
|
||||
style={{ shapeOutside: shape }}
|
||||
data-user-id={userId}
|
||||
data-pose={pose}
|
||||
onClick={onClick}
|
||||
|
|
@ -132,6 +59,6 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
|
|||
alt={`${mind}, ${pose}`}
|
||||
draggable={false}
|
||||
/>
|
||||
</Tag>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { alphaBounds, nestView } from './nest';
|
||||
import boxes from './safeBoxes.json';
|
||||
|
||||
describe('nestView', () => {
|
||||
// Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22).
|
||||
it('crops half the right and top margins for a top-right nest', () => {
|
||||
expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({
|
||||
x: 0,
|
||||
y: 11,
|
||||
width: 526,
|
||||
height: 363,
|
||||
});
|
||||
});
|
||||
|
||||
it('crops the left margin instead for a top-left nest', () => {
|
||||
expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({
|
||||
x: 72,
|
||||
y: 11,
|
||||
width: 488,
|
||||
height: 363,
|
||||
});
|
||||
});
|
||||
|
||||
it('never crops the top of art already touching it (smug, headband at y=0)', () => {
|
||||
const v = nestView([140, 0, 487, 241], [560, 374], true);
|
||||
expect(v.y).toBe(0);
|
||||
expect(v.height).toBe(374);
|
||||
});
|
||||
|
||||
it('can take the whole margin', () => {
|
||||
expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({
|
||||
x: 0,
|
||||
y: 4,
|
||||
width: 90,
|
||||
height: 56,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every measured safe box intact at the default share', () => {
|
||||
const aqua = boxes.aqua as Record<string, number[]>;
|
||||
expect(Object.keys(aqua)).toHaveLength(15);
|
||||
Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => {
|
||||
const right = nestView([x0, y0, x1, y1], [w, h], true);
|
||||
expect(right.x).toBeLessThanOrEqual(x0);
|
||||
expect(right.x + right.width).toBeGreaterThanOrEqual(x1);
|
||||
expect(right.y).toBeLessThanOrEqual(y0);
|
||||
const left = nestView([x0, y0, x1, y1], [w, h], false);
|
||||
expect(left.x).toBeLessThanOrEqual(x0);
|
||||
expect(left.x + left.width).toBe(w);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('alphaBounds', () => {
|
||||
it('finds the opaque rectangle', () => {
|
||||
const w = 6;
|
||||
const h = 5;
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
[
|
||||
[2, 1],
|
||||
[4, 3],
|
||||
].forEach(([x, y]) => {
|
||||
data[(y * w + x) * 4 + 3] = 255;
|
||||
});
|
||||
expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]);
|
||||
});
|
||||
|
||||
it('reports nothing for an empty image', () => {
|
||||
expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is
|
||||
// cut off by the bubble's edge, so nothing hangs outside it.
|
||||
|
||||
/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */
|
||||
export type Box = [number, number, number, number];
|
||||
|
||||
// Share of the room between the safe box and the image edge that the bubble edge may cut away.
|
||||
// Aqua's measurement says all of it can go; half leaves the outline a little air.
|
||||
export const CROP_SHARE = 0.5;
|
||||
|
||||
export type NestView = {
|
||||
/** The visible region of the image, in pixels. */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left
|
||||
// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top.
|
||||
// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust.
|
||||
export const nestView = (
|
||||
safe: Box,
|
||||
size: [number, number],
|
||||
right: boolean,
|
||||
share = CROP_SHARE
|
||||
): NestView => {
|
||||
const [w, h] = size;
|
||||
const [x0, y0, x1] = safe;
|
||||
const side = Math.round(share * (right ? w - x1 : x0));
|
||||
const top = Math.round(share * y0);
|
||||
const x = right ? 0 : side;
|
||||
return { x, y: top, width: w - side, height: h - top };
|
||||
};
|
||||
|
||||
// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one.
|
||||
export const alphaBounds = (
|
||||
data: ArrayLike<number>,
|
||||
w: number,
|
||||
h: number,
|
||||
min = 8
|
||||
): Box | undefined => {
|
||||
let x0 = w;
|
||||
let y0 = h;
|
||||
let x1 = 0;
|
||||
let y1 = 0;
|
||||
for (let y = 0; y < h; y += 1) {
|
||||
for (let x = 0; x < w; x += 1) {
|
||||
if (data[(y * w + x) * 4 + 3] > min) {
|
||||
x0 = Math.min(x0, x);
|
||||
x1 = Math.max(x1, x + 1);
|
||||
y0 = Math.min(y0, y);
|
||||
y1 = y + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return x1 > x0 ? [x0, y0, x1, y1] : undefined;
|
||||
};
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import catalogue from './catalogue.json';
|
||||
import safeBoxes from './safeBoxes.json';
|
||||
import type { Box } from './nest';
|
||||
|
||||
// Pose labels travel in the event content under POSE_KEY. The catalogue
|
||||
// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only
|
||||
|
|
@ -42,14 +40,3 @@ export const resolvePose = (mind: string, label: unknown): string => {
|
|||
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
|
||||
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
|
||||
|
||||
// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the
|
||||
// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width,
|
||||
// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time.
|
||||
const boxes = safeBoxes as Record<string, Record<string, number[]>>;
|
||||
export const safeBox = (
|
||||
mind: string,
|
||||
pose: string
|
||||
): { box: Box; size: [number, number] } | undefined => {
|
||||
const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined;
|
||||
return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"aqua": {
|
||||
"neutral": [144, 22, 492, 317, 560, 374],
|
||||
"happy": [157, 22, 506, 317, 574, 374],
|
||||
"amused": [154, 22, 500, 317, 562, 374],
|
||||
"thinking": [154, 16, 500, 307, 562, 374],
|
||||
"annoyed": [154, 4, 499, 282, 562, 374],
|
||||
"surprised": [141, 5, 485, 282, 560, 374],
|
||||
"concerned": [156, 4, 501, 283, 574, 374],
|
||||
"fretful": [156, 16, 503, 305, 574, 374],
|
||||
"weary": [161, 16, 509, 307, 624, 374],
|
||||
"glare": [160, 5, 508, 282, 624, 374],
|
||||
"smug": [140, 0, 487, 241, 560, 374],
|
||||
"sigh": [154, 0, 500, 241, 574, 374],
|
||||
"charging": [152, 0, 497, 241, 562, 374],
|
||||
"delighted": [159, 0, 507, 241, 624, 374],
|
||||
"content": [144, 16, 492, 306, 560, 374]
|
||||
}
|
||||
}
|
||||
|
|
@ -53,22 +53,4 @@ describe('silhouette', () => {
|
|||
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('traces only the visible part of a cropped portrait, drawn at its offset', () => {
|
||||
const drawImage = vi.fn();
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
canvas,
|
||||
drawImage,
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }),
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal('document', { createElement: () => canvas });
|
||||
const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement;
|
||||
silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 });
|
||||
expect([canvas.width, canvas.height]).toEqual([40, 8]);
|
||||
expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,23 +5,14 @@ const ROW = 4;
|
|||
// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and
|
||||
// anchors it at the margin box, which the phone layout's negative top margin shifts down by the
|
||||
// overhang, so the exclusion neither matches the art's size nor its place.
|
||||
//
|
||||
// `view` is the part of the displayed image that shows (the button's box, in displayed pixels):
|
||||
// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art.
|
||||
export type View = { x: number; y: number; width: number; height: number };
|
||||
|
||||
export const silhouette = (
|
||||
img: HTMLImageElement,
|
||||
textOnRight: boolean,
|
||||
view?: View
|
||||
): string | undefined => {
|
||||
const w = Math.round(view?.width ?? img.clientWidth);
|
||||
const h = Math.round(view?.height ?? img.clientHeight);
|
||||
export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => {
|
||||
const w = Math.round(img.clientWidth);
|
||||
const h = Math.round(img.clientHeight);
|
||||
const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null;
|
||||
if (!ctx) return undefined;
|
||||
ctx.canvas.width = w;
|
||||
ctx.canvas.height = h;
|
||||
ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const { data } = ctx.getImageData(0, 0, w, h);
|
||||
const outer = textOnRight ? 0 : w;
|
||||
const edge: string[] = [];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van
|
|||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||
import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
|
||||
import { surfaceInk } from '../../features/scene/sceneShell.css';
|
||||
import { skyLabelInk } from '../../features/scene/skyLabelInk';
|
||||
|
||||
// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows
|
||||
// are three distinct steps above them.
|
||||
|
|
@ -223,7 +222,6 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, {
|
|||
textDecorationLine: 'underline',
|
||||
});
|
||||
phoneLabel(`:root[data-scene] ${label}`, {
|
||||
color: skyLabelInk('inherit'),
|
||||
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`;
|
||||
|
||||
/** One column, bare ink on the scene. */
|
||||
export const Column = style({
|
||||
maxWidth: toRem(720),
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
padding: config.space.S400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
/** A tappable text row: no border, no fill, at least 44 px tall. */
|
||||
export const Row = style({
|
||||
all: 'unset',
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'pointer',
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: toRem(2),
|
||||
color: color.Background.OnContainer,
|
||||
':focus-visible': { textDecoration: 'underline' },
|
||||
});
|
||||
|
||||
export const Back = style([Row, { minHeight: 44 }]);
|
||||
|
||||
export const Files = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S400,
|
||||
});
|
||||
|
||||
/** Images may tile into a grid on wide screens only. */
|
||||
export const FilesWide = style({
|
||||
'@media': {
|
||||
[WIDE]: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
alignItems: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Dim = style({ opacity: config.opacity.P300 });
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Scroll, Text } from 'folds';
|
||||
import { Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { MediaControl } from '../../components/media';
|
||||
import { AudioContent } from '../../components/message';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { IAudioContent } from '../../../types/matrix/common';
|
||||
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { timeDayMonYear } from '../../utils/time';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { Album, AlbumFile } from './buildAlbums';
|
||||
import { useAlbums } from './useAlbums';
|
||||
import * as css from './AlbumView.css';
|
||||
|
||||
/**
|
||||
* Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the
|
||||
* title above it is the only label and the transport controls sit on the scene. Anything that is
|
||||
* not playable audio falls through to the stock renderer (a file download).
|
||||
*/
|
||||
const playableAudio = (content: Record<string, unknown>) => {
|
||||
const c = content as IAudioContent;
|
||||
const url = c.file?.url ?? c.url;
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? '');
|
||||
if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined;
|
||||
return { info: c.info, url, mimeType, encInfo: c.file };
|
||||
};
|
||||
|
||||
type FileRowProps = { room: Room; file: AlbumFile; author: string };
|
||||
function FileRow({ room, file, author }: FileRowProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const htmlReactParserOptions = useMemo(
|
||||
() =>
|
||||
getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }),
|
||||
[mx, room.roomId, useAuthentication]
|
||||
);
|
||||
const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined;
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
{audio ? (
|
||||
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={author}
|
||||
msgType={file.content.msgtype as string}
|
||||
ts={file.ts}
|
||||
getContent={<T,>() => file.content as T}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={LINKIFY_OPTS}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
|
||||
|
||||
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
|
||||
function useMarkZoneRead(room: Room) {
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
useEffect(() => {
|
||||
const mark = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity).catch(() => undefined);
|
||||
};
|
||||
mark();
|
||||
room.on(RoomEvent.Timeline, mark);
|
||||
return () => {
|
||||
room.off(RoomEvent.Timeline, mark);
|
||||
};
|
||||
}, [mx, room, hideActivity]);
|
||||
}
|
||||
|
||||
type AlbumViewProps = { room: Room; onTimeline: () => void };
|
||||
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
||||
useMarkZoneRead(room);
|
||||
const { loading, error, albums } = useAlbums(room);
|
||||
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [openId, setOpenId] = useState<string>();
|
||||
const open: Album | undefined = albums.find((a) => a.id === openId);
|
||||
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
|
||||
const encrypted = room.hasEncryptionStateEvent();
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading) body = <Text>Loading albums…</Text>;
|
||||
else if (error) body = <Text>Could not load albums: {error.message}</Text>;
|
||||
else if (open) {
|
||||
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
|
||||
body = (
|
||||
<>
|
||||
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
</button>
|
||||
<Text size="H4">{open.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
|
||||
</Text>
|
||||
{open.count === 0 && <Text>This album has no files.</Text>}
|
||||
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
|
||||
{open.files.map((f) => (
|
||||
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (encrypted)
|
||||
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
|
||||
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
|
||||
else {
|
||||
body = albums.map((a) => (
|
||||
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
|
||||
<Text size="T400">{a.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
|
||||
</Text>
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<div className={css.Column}>
|
||||
{body}
|
||||
<button type="button" className={css.Row} onClick={onTimeline}>
|
||||
<Text size="B400">Timeline ›</Text>
|
||||
</button>
|
||||
</div>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
|
||||
|
||||
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
|
||||
event_id: id,
|
||||
type: 'm.room.message',
|
||||
sender: '@void:x',
|
||||
origin_server_ts: ts,
|
||||
content: { msgtype: 'm.text', body: `album ${id}` },
|
||||
...over,
|
||||
});
|
||||
const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({
|
||||
event_id: id,
|
||||
type: 'm.room.message',
|
||||
sender: '@void:x',
|
||||
origin_server_ts: ts,
|
||||
content: { msgtype, body, ...extra },
|
||||
});
|
||||
const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0];
|
||||
|
||||
describe('buildAlbums', () => {
|
||||
it('lists the newest album first', () => {
|
||||
const inputs: AlbumInput[] = [
|
||||
{ root: root('$old', 100), replies: [] },
|
||||
{ root: root('$new', 300), replies: [] },
|
||||
{ root: root('$mid', 200), replies: [] },
|
||||
];
|
||||
expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']);
|
||||
});
|
||||
|
||||
it('takes title, author and date from the root', () => {
|
||||
const a = one(root('$r', 555, { sender: '@eureka:x' }), []);
|
||||
expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]);
|
||||
});
|
||||
|
||||
it('lists files oldest first whatever order the server returned them in', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$c', 30, 'm.audio'),
|
||||
reply('$a', 10, 'm.audio'),
|
||||
reply('$b', 20, 'm.audio'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']);
|
||||
});
|
||||
|
||||
it('counts media replies only; a text reply is not a file', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$t', 5, 'm.text', 'nice'),
|
||||
reply('$n', 6, 'm.notice', 'bot'),
|
||||
reply('$a', 10, 'm.audio'),
|
||||
reply('$f', 11, 'm.file'),
|
||||
]);
|
||||
expect(a.count).toBe(2);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']);
|
||||
});
|
||||
|
||||
it('keeps a root with no media as an album of zero files', () => {
|
||||
const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]);
|
||||
expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined });
|
||||
});
|
||||
|
||||
it('uses the first image as the cover', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$a', 5, 'm.audio'),
|
||||
reply('$i2', 20, 'm.image'),
|
||||
reply('$i1', 10, 'm.image'),
|
||||
]);
|
||||
expect(a.cover?.id).toBe('$i1');
|
||||
expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives the kind from the msgtype', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$1', 1, 'm.audio'),
|
||||
reply('$2', 2, 'm.image'),
|
||||
reply('$3', 3, 'm.video'),
|
||||
reply('$4', 4, 'm.file'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']);
|
||||
});
|
||||
|
||||
it('names a file by filename, else body', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }),
|
||||
reply('$2', 2, 'm.audio', 'plain.wav'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']);
|
||||
});
|
||||
|
||||
it('does not duplicate a file the relations endpoint returned twice', () => {
|
||||
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]);
|
||||
expect(a.count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not duplicate an album listed twice', () => {
|
||||
expect(
|
||||
buildAlbums([
|
||||
{ root: root('$r', 1), replies: [] },
|
||||
{ root: root('$r', 1), replies: [] },
|
||||
])
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips redacted and empty events', () => {
|
||||
const redacted = {
|
||||
...reply('$x', 5, 'm.audio'),
|
||||
content: {},
|
||||
unsigned: { redacted_because: {} },
|
||||
};
|
||||
const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } };
|
||||
const empty: ZoneEvent = { event_id: '$z' };
|
||||
const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
|
||||
expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]);
|
||||
expect(
|
||||
buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates events with missing fields', () => {
|
||||
const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [
|
||||
{ event_id: '$a', content: { msgtype: 'm.audio' } },
|
||||
]);
|
||||
expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 });
|
||||
});
|
||||
|
||||
it('does not read inherited object keys as file kinds', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$c', 1, 'constructor'),
|
||||
reply('$p', 2, '__proto__'),
|
||||
reply('$t', 3, 'toString'),
|
||||
]);
|
||||
expect(a.count).toBe(0);
|
||||
});
|
||||
|
||||
it('skips events that are not room messages even if their content looks like media', () => {
|
||||
const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' };
|
||||
const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
|
||||
expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips events without an event id', () => {
|
||||
const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } };
|
||||
expect(one(root('$r', 1), [anon]).count).toBe(0);
|
||||
expect(
|
||||
buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('titles an edited album by its latest edit', () => {
|
||||
const edited = root('$r', 1, {
|
||||
unsigned: {
|
||||
'm.relations': {
|
||||
'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(one(edited, []).title).toBe('renamed');
|
||||
const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } });
|
||||
expect(one(emptyEdit, []).title).toBe('album $q');
|
||||
});
|
||||
|
||||
it('survives null or non-object entries in inputs and replies', () => {
|
||||
const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[];
|
||||
const a = buildAlbums([
|
||||
null as unknown as AlbumInput,
|
||||
{ root: null as unknown as ZoneEvent, replies: [] },
|
||||
{ root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] },
|
||||
{ root: root('$s', 2), replies: null as unknown as ZoneEvent[] },
|
||||
]);
|
||||
expect(a.map((x) => x.id)).toEqual(['$s', '$r']);
|
||||
expect(a[1].files.map((f) => f.id)).toEqual(['$ok']);
|
||||
});
|
||||
|
||||
it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$b', 20, 'm.audio'),
|
||||
{ ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number },
|
||||
{ ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN },
|
||||
reply('$a', 10, 'm.audio'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']);
|
||||
const albums = buildAlbums([
|
||||
{ root: root('$x', 100), replies: [] },
|
||||
{ root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] },
|
||||
]);
|
||||
expect(albums.map((x) => [x.id, x.ts])).toEqual([
|
||||
['$x', 100],
|
||||
['$y', 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an album with an empty body a fallback title', () => {
|
||||
expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe(
|
||||
'Untitled album'
|
||||
);
|
||||
expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album');
|
||||
});
|
||||
|
||||
it('never counts an edit event as a file or an album', () => {
|
||||
const edit = reply('$e', 9, 'm.audio', '* x', {
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$a' },
|
||||
'm.new_content': { msgtype: 'm.audio', body: 'x' },
|
||||
});
|
||||
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$a']);
|
||||
const rootEdit = root('$re', 1, {
|
||||
content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } },
|
||||
});
|
||||
expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]);
|
||||
const threaded = reply('$t', 6, 'm.audio', 't', {
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$r' },
|
||||
});
|
||||
expect(one(root('$q', 1), [threaded]).count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
|
||||
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
|
||||
// into albums. See docs/file-zone.md for the rulings this encodes.
|
||||
|
||||
/** The slice of a Matrix event this module reads. Every field may be missing. */
|
||||
export type ZoneEvent = {
|
||||
event_id?: string;
|
||||
type?: string;
|
||||
sender?: string;
|
||||
origin_server_ts?: number;
|
||||
content?: Record<string, unknown>;
|
||||
unsigned?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] };
|
||||
|
||||
export type FileKind = 'audio' | 'image' | 'video' | 'file';
|
||||
|
||||
export type AlbumFile = {
|
||||
id: string;
|
||||
kind: FileKind;
|
||||
name: string;
|
||||
ts: number;
|
||||
/** The event content, as RenderMessageContent reads it. */
|
||||
content: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Album = {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
ts: number;
|
||||
cover?: AlbumFile;
|
||||
files: AlbumFile[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind.
|
||||
const KIND_OF_MSGTYPE = new Map<string, FileKind>([
|
||||
['m.audio', 'audio'],
|
||||
['m.image', 'image'],
|
||||
['m.video', 'video'],
|
||||
['m.file', 'file'],
|
||||
]);
|
||||
|
||||
// A redacted event keeps its id but loses its content and gains unsigned.redacted_because.
|
||||
const live = (
|
||||
e: ZoneEvent
|
||||
): e is ZoneEvent & { event_id: string; content: Record<string, unknown> } =>
|
||||
!!e &&
|
||||
typeof e === 'object' &&
|
||||
!!e.event_id &&
|
||||
// An edit is an m.room.message too, but it revises an event; it is never a file or an album.
|
||||
(e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' &&
|
||||
!e.unsigned?.redacted_because &&
|
||||
(e.type === undefined || e.type === 'm.room.message') &&
|
||||
typeof e.content?.msgtype === 'string';
|
||||
|
||||
const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
|
||||
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
|
||||
if (!live(e)) return undefined;
|
||||
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
|
||||
if (!kind) return undefined;
|
||||
return {
|
||||
id: e.event_id,
|
||||
kind,
|
||||
name: asString(e.content.filename) || asString(e.content.body),
|
||||
ts: tsOf(e.origin_server_ts),
|
||||
content: e.content,
|
||||
};
|
||||
};
|
||||
|
||||
export const UNTITLED = 'Untitled album';
|
||||
|
||||
// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is
|
||||
// that edit's m.new_content.body, else the original body.
|
||||
const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string => {
|
||||
const relations = root.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
|
||||
const edit = relations?.['m.replace'] as ZoneEvent | undefined;
|
||||
const edited = (edit?.content?.['m.new_content'] as Record<string, unknown> | undefined)?.body;
|
||||
return asString(edited) || asString(root.content.body) || UNTITLED;
|
||||
};
|
||||
|
||||
const toAlbum = (input: AlbumInput): Album | undefined => {
|
||||
const { root, replies } = input ?? {};
|
||||
if (!live(root)) return undefined;
|
||||
const seen = new Set<string>();
|
||||
const files = (Array.isArray(replies) ? replies : [])
|
||||
.map(toFile)
|
||||
.filter((f): f is AlbumFile => {
|
||||
if (!f || seen.has(f.id)) return false;
|
||||
seen.add(f.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
return {
|
||||
id: root.event_id,
|
||||
title: titleOf(root),
|
||||
author: root.sender ?? '',
|
||||
ts: tsOf(root.origin_server_ts),
|
||||
cover: files.find((f) => f.kind === 'image'),
|
||||
files,
|
||||
count: files.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Newest album first; files oldest first. Loose room messages never reach this function. */
|
||||
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
|
||||
const seen = new Set<string>();
|
||||
return inputs
|
||||
.map(toAlbum)
|
||||
.filter((a): a is Album => {
|
||||
if (!a || seen.has(a.id)) return false;
|
||||
seen.add(a.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.ts - a.ts);
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { isFileZone } from './isFileZone';
|
||||
|
||||
const room = { roomId: '!f:x', aliases: ['#files:x'] };
|
||||
|
||||
describe('isFileZone', () => {
|
||||
it('is true for the file-zone create type', () => {
|
||||
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true);
|
||||
});
|
||||
it('is false for other create types and for plain rooms', () => {
|
||||
expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false);
|
||||
expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false);
|
||||
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false);
|
||||
expect(isFileZone(room, {})).toBe(false);
|
||||
expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false);
|
||||
});
|
||||
it('is true when the room id or an alias is configured', () => {
|
||||
expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true);
|
||||
expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true);
|
||||
});
|
||||
it('is false when only another room is configured', () => {
|
||||
expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
export const FILE_ZONE_TYPE = 'org.gamecult.file_zone';
|
||||
|
||||
export type FileZoneRoom = {
|
||||
roomId: string;
|
||||
/** Canonical and alternative aliases. */
|
||||
aliases?: string[];
|
||||
/** The `type` of the room's m.room.create content. */
|
||||
createType?: string;
|
||||
};
|
||||
|
||||
/** A room is the file zone when its create type says so or the client config lists it. */
|
||||
export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean =>
|
||||
room.createType === FILE_ZONE_TYPE ||
|
||||
!!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r));
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { mapLimit } from './mapLimit';
|
||||
|
||||
describe('mapLimit', () => {
|
||||
it('keeps result order and never exceeds the limit', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, n);
|
||||
});
|
||||
inFlight -= 1;
|
||||
return n * 10;
|
||||
});
|
||||
expect(out).toEqual([50, 10, 40, 20, 30, 0]);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
it('handles an empty list and rejects when a call rejects', async () => {
|
||||
expect(await mapLimit([], 3, async (n) => n)).toEqual([]);
|
||||
await expect(
|
||||
mapLimit([1, 2], 2, async (n) => {
|
||||
if (n === 2) throw new Error('boom');
|
||||
return n;
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */
|
||||
export const mapLimit = async <T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next;
|
||||
next += 1;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
};
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ClientPrefix, Method, Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { Album, ZoneEvent, buildAlbums } from './buildAlbums';
|
||||
import { isFileZone } from './isFileZone';
|
||||
import { mapLimit } from './mapLimit';
|
||||
|
||||
const REPLY_FETCHES_IN_FLIGHT = 6;
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
type Chunk = { chunk?: ZoneEvent[] };
|
||||
|
||||
/** True when the room is the file zone: by its create type, or listed in the client config. */
|
||||
export const useIsFileZone = (room: Room): boolean => {
|
||||
const config = useClientConfig();
|
||||
// Subscribes to alias changes (alt aliases live in the same event).
|
||||
useStateEvent(room, StateEvent.RoomCanonicalAlias);
|
||||
const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type;
|
||||
return isFileZone(
|
||||
{
|
||||
roomId: room.roomId,
|
||||
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a),
|
||||
createType,
|
||||
},
|
||||
config
|
||||
);
|
||||
};
|
||||
|
||||
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
|
||||
|
||||
/**
|
||||
* Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with
|
||||
* more albums or files than that is truncated; pagination is a later slice. Reads are made with
|
||||
* the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and
|
||||
* are skipped.
|
||||
*/
|
||||
export const useAlbums = (room: Room): AlbumsState => {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId } = room;
|
||||
const query = useQuery({
|
||||
queryKey: ['albums', roomId],
|
||||
queryFn: async (): Promise<Album[]> => {
|
||||
const get = (path: string, params: Record<string, string>) =>
|
||||
mx.http.authedRequest<Chunk>(Method.Get, path, params, undefined, {
|
||||
prefix: ClientPrefix.V1,
|
||||
});
|
||||
const room_ = encodeURIComponent(roomId);
|
||||
const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? [];
|
||||
const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({
|
||||
root,
|
||||
replies:
|
||||
(
|
||||
await get(
|
||||
`/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`,
|
||||
{ limit: '100', dir: 'f' }
|
||||
)
|
||||
).chunk ?? [],
|
||||
}));
|
||||
return buildAlbums(inputs);
|
||||
},
|
||||
retry: 1,
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
return {
|
||||
loading: query.isLoading,
|
||||
// A failed refetch keeps the albums already loaded; the error shows only when there are none.
|
||||
error: query.data ? null : (query.error as Error | null),
|
||||
albums: query.data ?? [],
|
||||
};
|
||||
};
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { zoneView } from './zoneView';
|
||||
|
||||
describe('zoneView', () => {
|
||||
it('opens albums by default and the timeline for an event deep link', () => {
|
||||
expect(zoneView(undefined, undefined)).toBe('albums');
|
||||
expect(zoneView(undefined, '$e')).toBe('timeline');
|
||||
});
|
||||
it('lets an event link win over an earlier choice of albums', () => {
|
||||
expect(zoneView('albums', '$e')).toBe('timeline');
|
||||
});
|
||||
it('honours the choice when there is no event link', () => {
|
||||
expect(zoneView('albums', undefined)).toBe('albums');
|
||||
expect(zoneView('timeline', undefined)).toBe('timeline');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export type ZoneView = 'albums' | 'timeline';
|
||||
|
||||
/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */
|
||||
export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView =>
|
||||
eventId ? 'timeline' : choice ?? 'albums';
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { getHomePath } from '../../pages/pathUtils';
|
||||
import { DeskView } from './DeskView';
|
||||
import { unverifiedForks } from './optionsHash';
|
||||
import { deskFor } from './source';
|
||||
|
||||
const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1);
|
||||
|
||||
/** The route's page: one Mind's desk under the navigator. */
|
||||
export function Desk() {
|
||||
const { mind = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const label = labelOf(mind);
|
||||
const list = useMemo(() => deskFor(mind), [mind]);
|
||||
// Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands.
|
||||
const [unverified, setUnverified] = useState<ReadonlySet<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{phone && (
|
||||
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="H3" truncate>{`${label}'s desk`}</Text>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<DeskView mind={mind} label={label} list={list} unverified={unverified} />
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Text } from 'folds';
|
||||
import { getDeskPath } from '../../pages/pathUtils';
|
||||
import { deskFor } from './source';
|
||||
|
||||
/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */
|
||||
export function DeskEntry({ mind }: { mind: string }) {
|
||||
const open = deskFor(mind)?.open_count ?? 0;
|
||||
if (open === 0) return null;
|
||||
return (
|
||||
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
|
||||
{`Desk · ${open} open`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
|
||||
/** Rows and booth side by side on wide screens. */
|
||||
export const Layout = style({
|
||||
display: 'flex',
|
||||
gap: config.space.S500,
|
||||
alignItems: 'flex-start',
|
||||
'@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } },
|
||||
});
|
||||
|
||||
export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' });
|
||||
|
||||
/** A hairline splits rows; it is the only chrome. */
|
||||
export const Row = style({
|
||||
padding: `${config.space.S300} 0`,
|
||||
borderTop: `1px solid ${color.Background.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const Title = style({
|
||||
margin: 0,
|
||||
font: 'inherit',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
/** The fold button reads as the title itself, not as a control. */
|
||||
export const Fold = style({
|
||||
all: 'unset',
|
||||
cursor: 'pointer',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
minHeight: 44,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` });
|
||||
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
|
||||
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
|
||||
export const Option = style({ padding: `${config.space.S100} 0` });
|
||||
|
||||
export const Note = style({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: 44,
|
||||
margin: `${config.space.S200} 0`,
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
background: 'none',
|
||||
border: `1px solid ${color.Background.ContainerLine}`,
|
||||
opacity: config.opacity.Disabled,
|
||||
});
|
||||
|
||||
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
|
||||
export const Booth = style({
|
||||
position: 'sticky',
|
||||
top: config.space.S400,
|
||||
width: toRem(168),
|
||||
flexShrink: 0,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
position: 'fixed',
|
||||
top: 'auto',
|
||||
right: config.space.S200,
|
||||
bottom: config.space.S200,
|
||||
width: toRem(88),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow.
|
||||
globalStyle(`${Booth} > [data-pose]`, {
|
||||
position: 'static',
|
||||
float: 'none',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
margin: 0,
|
||||
maskImage: 'none',
|
||||
WebkitMaskImage: 'none',
|
||||
cursor: 'default',
|
||||
});
|
||||
|
||||
export const BoothLine = style({
|
||||
'@media': {
|
||||
// Still read out by the live region; only the picture shows in the corner.
|
||||
[PHONE]: {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
|
||||
import { DeskEntry } from './DeskEntry';
|
||||
import { DeskView, SIGN_IN_NOTE } from './DeskView';
|
||||
import { STATES } from './fixtures';
|
||||
import { setDeskSource } from './source';
|
||||
|
||||
const view = (
|
||||
state: keyof typeof STATES | 'unpublished',
|
||||
size: ScreenSize,
|
||||
mind = 'void',
|
||||
unverified = new Set<string>()
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
<ScreenSizeProvider value={size}>
|
||||
<DeskView
|
||||
mind={mind}
|
||||
label="Void"
|
||||
list={state === 'unpublished' ? undefined : STATES[state]}
|
||||
unverified={unverified}
|
||||
/>
|
||||
</ScreenSizeProvider>
|
||||
);
|
||||
|
||||
describe('DeskView', () => {
|
||||
it('folds a row behind its title on a phone, with the fold button saying so', () => {
|
||||
const html = view('noAnswers', ScreenSize.Mobile);
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('Question plain');
|
||||
expect(html).not.toContain('Context for plain');
|
||||
expect(html).not.toContain('First way');
|
||||
});
|
||||
it('shows the row open, with no fold button, on a wide screen', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html).not.toContain('aria-expanded');
|
||||
expect(html).toContain('Context for plain');
|
||||
expect(html).toContain('First way');
|
||||
});
|
||||
it('states the recommendation in words, on that option only', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html.match(/\(recommended\)/g)).toHaveLength(1);
|
||||
expect(html).toContain('B Second way (recommended)');
|
||||
});
|
||||
it('keeps the note disabled and says why in a sentence the field points at', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html).toMatch(/<textarea[^>]* disabled=""/);
|
||||
expect(html).toContain(SIGN_IN_NOTE);
|
||||
expect(SIGN_IN_NOTE).toMatch(/sign-in/);
|
||||
expect(html).toMatch(/aria-describedby="note-[^"]+-why"/);
|
||||
});
|
||||
it('shows the depends line as the text it is', () => {
|
||||
expect(view('blocked', ScreenSize.Desktop)).toContain(
|
||||
'Waits on: "Needs the first one settled"'
|
||||
);
|
||||
});
|
||||
it('says No answers yet on a row without answers', () => {
|
||||
expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.');
|
||||
});
|
||||
it('lists settled questions as recorded rulings with the ruling id', () => {
|
||||
const html = view('settled', ScreenSize.Desktop);
|
||||
expect(html).toContain('Recorded as rulings');
|
||||
expect(html).toContain('Question done (ruling:done-1)');
|
||||
expect(html).not.toContain('Question shut');
|
||||
});
|
||||
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
|
||||
const html = view('answered', ScreenSize.Desktop);
|
||||
expect(html).toContain(
|
||||
'Answer First way "Because it is first." (2026-11-09) (counted)'
|
||||
);
|
||||
expect(html).toContain('Revision of n1 Third way (2026-11-09)');
|
||||
expect(html).toContain('Retraction of n2 (2026-11-09)');
|
||||
expect(html).toContain('Refused (not_a_decider) Second way "Let me in."');
|
||||
expect(html).toContain('Answer "Either, if measured first."');
|
||||
expect(html.match(/\(counted\)/g)).toHaveLength(1);
|
||||
expect(html).not.toContain('"null"');
|
||||
});
|
||||
it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => {
|
||||
expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.');
|
||||
expect(view('unpublished', ScreenSize.Desktop)).toContain(
|
||||
'Void's questions have not been published yet.'
|
||||
);
|
||||
});
|
||||
it('seats the Mind in the booth with the pose for the desk mood', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html).toContain('data-pose="thinking"');
|
||||
expect(html).toContain('Just one fork on the desk.');
|
||||
});
|
||||
it('makes the booth portrait a plain image: no button, no tab stop', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
const booth = html.slice(html.indexOf('data-pose'));
|
||||
expect(booth).toContain('<img');
|
||||
expect(html).not.toMatch(/<button[^>]*data-pose/);
|
||||
expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/);
|
||||
expect(html).toMatch(/<span[^>]*data-pose/);
|
||||
});
|
||||
it('withdraws a card whose hash does not match and does not count it open', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain']));
|
||||
expect(html).toContain('does not match the hash it was published with');
|
||||
expect(html).not.toContain('First way');
|
||||
expect(html).toContain('1 open.');
|
||||
expect(html).not.toContain('Nothing is waiting');
|
||||
});
|
||||
it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => {
|
||||
setDeskSource(() => undefined);
|
||||
const html = renderToStaticMarkup(
|
||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
||||
<DeskView mind="void" label="Void" list={{ ...STATES.empty, open_count: 3 }} />
|
||||
</ScreenSizeProvider>
|
||||
);
|
||||
expect(html).toContain('3 open.');
|
||||
expect(html).not.toContain('Nothing is waiting');
|
||||
});
|
||||
it('shows no booth for a Mind without art rather than another face', () => {
|
||||
expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose');
|
||||
});
|
||||
it('shows no booth before anything is published', () => {
|
||||
expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeskEntry', () => {
|
||||
afterEach(() => setDeskSource(() => undefined));
|
||||
const entry = (mind: string) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<DeskEntry mind={mind} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
it('reads "Desk · N open" and links to that Mind\'s desk', () => {
|
||||
setDeskSource(() => STATES.blocked);
|
||||
const html = entry('void');
|
||||
expect(html).toContain('Desk · 2 open');
|
||||
expect(html).toContain('href="/desk/void"');
|
||||
});
|
||||
it('reads the count the list states, not a count of the forks it carries', () => {
|
||||
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
|
||||
expect(entry('void')).toContain('Desk · 5 open');
|
||||
});
|
||||
it('is absent at zero open, when nothing is published, and when only settled forks exist', () => {
|
||||
setDeskSource(() => STATES.empty);
|
||||
expect(entry('void')).toBe('');
|
||||
setDeskSource(() => undefined);
|
||||
expect(entry('void')).toBe('');
|
||||
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
|
||||
expect(entry('void')).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Text } from 'folds';
|
||||
import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
|
||||
import { MINDS } from '../../components/message/pose/poses';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
|
||||
import { ForkList } from './forkContract';
|
||||
import { deskMood } from './deskMood';
|
||||
import * as css from './DeskView.css';
|
||||
|
||||
/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */
|
||||
export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.';
|
||||
|
||||
const date = (iso: string) => iso.slice(0, 10);
|
||||
|
||||
const OUTCOME: Record<AnswerRow['outcome'], string> = {
|
||||
Counted: 'Answer',
|
||||
Revision: 'Revision',
|
||||
Retraction: 'Retraction',
|
||||
Refused: 'Refused',
|
||||
};
|
||||
|
||||
/** One answer as served: its outcome, what it chose, its note when the server sent one, and whether it counts. */
|
||||
const answerLine = (a: AnswerRow) =>
|
||||
[
|
||||
OUTCOME[a.outcome] + (a.code ? ` (${a.code})` : ''),
|
||||
a.target && `of ${a.target}`,
|
||||
a.choice,
|
||||
a.note !== undefined && `"${a.note}"`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
function Answers({ answers }: { answers: AnswerRow[] }) {
|
||||
if (answers.length === 0) return <p className={css.Quiet}>No answers yet.</p>;
|
||||
return (
|
||||
<ul className={css.Options}>
|
||||
{answers.map((a) => (
|
||||
<li key={a.answerId} className={css.Option}>
|
||||
{`${answerLine(a)} (${date(a.at)})`}
|
||||
{a.counted && ' (counted)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row }: { row: DeskRow }) {
|
||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
// Phones list titles; a tap opens one. Wide screens show every row open.
|
||||
const [open, setOpen] = useState(!phone);
|
||||
if (row.kind === 'unverified') {
|
||||
return (
|
||||
<li className={css.Row}>
|
||||
<p className={css.Quiet}>
|
||||
A question here does not match the hash it was published with, so it is not shown.
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const noteId = `note-${row.forkId}`;
|
||||
return (
|
||||
<li className={css.Row}>
|
||||
<h3 className={css.Title}>
|
||||
{phone ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Fold}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
) : (
|
||||
row.title
|
||||
)}
|
||||
</h3>
|
||||
{open && (
|
||||
<>
|
||||
<p className={css.Body}>{row.context}</p>
|
||||
<ul className={css.Options}>
|
||||
{row.options.map((o) => (
|
||||
<li key={o.id} className={css.Option}>
|
||||
{`${o.id} ${o.text}`}
|
||||
{o.recommended && ' (recommended)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{row.depends.map((d) => (
|
||||
<p key={d} className={css.Quiet}>
|
||||
{`Waits on: "${d}"`}
|
||||
</p>
|
||||
))}
|
||||
<span id={`${noteId}-label`}>Your note</span>
|
||||
<textarea
|
||||
className={css.Note}
|
||||
disabled
|
||||
aria-labelledby={`${noteId}-label`}
|
||||
aria-describedby={`${noteId}-why`}
|
||||
/>
|
||||
<p id={`${noteId}-why`} className={css.Quiet}>
|
||||
{SIGN_IN_NOTE}
|
||||
</p>
|
||||
<Answers answers={row.answers} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ desk, label }: { desk: Desk; label: string }) {
|
||||
if (!desk.published) return <p>{`${label}'s questions have not been published yet.`}</p>;
|
||||
return (
|
||||
<p role="status">
|
||||
{desk.counts.open === 0
|
||||
? `Nothing is waiting on you from ${label}. `
|
||||
: `${desk.counts.open} open.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type DeskViewProps = {
|
||||
/** The Mind's key, as the navigator groups it. */
|
||||
mind: string;
|
||||
label: string;
|
||||
list?: ForkList;
|
||||
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
|
||||
unverified?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
||||
export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
|
||||
const desk = buildDesk(list, unverified);
|
||||
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
|
||||
const mood = deskMood(desk.counts.open, 0);
|
||||
return (
|
||||
<section aria-label={`${label}'s desk`}>
|
||||
<Status desk={desk} label={label} />
|
||||
<div className={css.Layout}>
|
||||
<ol className={css.Rows}>
|
||||
{desk.open.map((row) => (
|
||||
<Row key={row.forkId} row={row} />
|
||||
))}
|
||||
</ol>
|
||||
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
|
||||
{desk.published && MINDS.includes(mind) && (
|
||||
<div className={css.Booth}>
|
||||
<PoseAvatar mind={mind} pose={mood.pose} userId={mind} />
|
||||
<Text className={css.BoothLine} size="T200" aria-live="polite">
|
||||
{mood.line}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{desk.recorded.length > 0 && (
|
||||
<>
|
||||
<h3 className={css.Title}>Recorded as rulings</h3>
|
||||
<ul className={css.Options}>
|
||||
{desk.recorded.map((r) => (
|
||||
<li key={r.forkId} className={css.Option}>
|
||||
{`${r.title} (${r.rulingId})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDesk } from './buildDesk';
|
||||
import { STATES, entry, list } from './fixtures';
|
||||
|
||||
const questions = (d: ReturnType<typeof buildDesk>) =>
|
||||
d.open.flatMap((r) => (r.kind === 'question' ? [r] : []));
|
||||
|
||||
describe('buildDesk', () => {
|
||||
it('says "not published" for a list that never arrived, never "no questions"', () => {
|
||||
expect(buildDesk(undefined)).toMatchObject({ published: false, open: [], counts: { open: 0 } });
|
||||
});
|
||||
it('is published and empty when the list holds nothing', () => {
|
||||
expect(buildDesk(STATES.empty)).toMatchObject({
|
||||
published: true,
|
||||
open: [],
|
||||
counts: { open: 0 },
|
||||
});
|
||||
});
|
||||
it('shows no answers as an empty list on an open row', () => {
|
||||
expect(questions(buildDesk(STATES.noAnswers))[0].answers).toEqual([]);
|
||||
});
|
||||
it('resolves the recommendation to its option, marks only that option, and never preselects', () => {
|
||||
const [row] = questions(buildDesk(STATES.noAnswers));
|
||||
expect(row.recommended).toBe('Second way');
|
||||
expect(row.options.filter((o) => o.recommended).map((o) => o.id)).toEqual(['B']);
|
||||
});
|
||||
it('leaves the recommendation unresolved when it names no option', () => {
|
||||
const [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })])));
|
||||
expect(row.recommended).toBeUndefined();
|
||||
expect(row.options.some((o) => o.recommended)).toBe(false);
|
||||
});
|
||||
it('carries the depends lines as the text they are', () => {
|
||||
const rows = questions(buildDesk(STATES.blocked));
|
||||
expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([
|
||||
'Needs the first one settled',
|
||||
]);
|
||||
expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]);
|
||||
});
|
||||
it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => {
|
||||
const d = buildDesk(STATES.settled);
|
||||
expect(d.recorded).toEqual([
|
||||
{ forkId: 'c:question:done', title: 'Question done', rulingId: 'ruling:done-1' },
|
||||
]);
|
||||
expect(d.open.map((r) => r.forkId)).toEqual(['c:question:live']);
|
||||
});
|
||||
it('keeps the served order (newest first by card.order) and sorts nothing itself', () => {
|
||||
const served = list([entry('late', { order: 9 }), entry('early', { order: 2 })]);
|
||||
expect(buildDesk(served).open.map((r) => r.forkId)).toEqual([
|
||||
'c:question:late',
|
||||
'c:question:early',
|
||||
]);
|
||||
const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]);
|
||||
expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']);
|
||||
});
|
||||
it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => {
|
||||
const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only']));
|
||||
expect(d.counts.open).toBe(4);
|
||||
});
|
||||
it('marks the answer the record names as counted, and no other', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.filter((a) => a.counted).map((a) => a.answerId)).toEqual(['n1']);
|
||||
});
|
||||
it('follows the record when a revision is the counted answer: nothing is re-derived', () => {
|
||||
const e = STATES.answered.forks[0];
|
||||
const moved = list([{ ...e, record: { ...e.record, counted_answer_id: 'n2' } }]);
|
||||
expect(
|
||||
questions(buildDesk(moved))[0]
|
||||
.answers.filter((a) => a.counted)
|
||||
.map((a) => a.answerId)
|
||||
).toEqual(['n2']);
|
||||
});
|
||||
it('keeps answers in the served order and carries each outcome, target and refusal code', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.map((a) => [a.answerId, a.outcome, a.target, a.code])).toEqual([
|
||||
['n1', 'Counted', undefined, undefined],
|
||||
['n2', 'Revision', 'n1', undefined],
|
||||
['n3', 'Retraction', 'n2', undefined],
|
||||
['n4', 'Refused', undefined, 'not_a_decider'],
|
||||
['n5', 'Counted', undefined, undefined],
|
||||
]);
|
||||
});
|
||||
it('shows a choice as option text, passes an unknown id through, and leaves a null choice absent', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.map((a) => a.choice)).toEqual([
|
||||
'First way',
|
||||
'Third way',
|
||||
undefined,
|
||||
'Second way',
|
||||
undefined,
|
||||
]);
|
||||
const odd = list([
|
||||
entry('odd', {}, [{ ...STATES.answered.forks[0].record.answers[0], choice: 'Q' }]),
|
||||
]);
|
||||
expect(questions(buildDesk(odd))[0].answers[0].choice).toBe('Q');
|
||||
});
|
||||
it('carries a note verbatim when present and leaves it absent when null', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers[0].note).toBe('Because it is first.');
|
||||
expect(row.answers[1].note).toBeUndefined();
|
||||
});
|
||||
it('withdraws an unverified open card and does not count it', () => {
|
||||
const d = buildDesk(STATES.noAnswers, new Set(['c:question:plain']));
|
||||
expect(d.open).toEqual([{ kind: 'unverified', forkId: 'c:question:plain' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { DeskAnswer, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
export type AnswerRow = {
|
||||
answerId: string;
|
||||
outcome: DeskAnswer['outcome'];
|
||||
/** The option's text for a choice. */
|
||||
choice?: string;
|
||||
/** Verbatim, and only when the server sent one. */
|
||||
note?: string;
|
||||
/** The answer a Revision or Retraction points at. */
|
||||
target?: string;
|
||||
/** A refusal's code. */
|
||||
code?: string;
|
||||
at: string;
|
||||
/** The record's counted answer: marked as served, never derived here. */
|
||||
counted: boolean;
|
||||
};
|
||||
|
||||
export type DeskRow =
|
||||
/** Its published options_hash is not the hash of what it shows: neither shown nor counted. */
|
||||
| { kind: 'unverified'; forkId: string }
|
||||
| {
|
||||
kind: 'question';
|
||||
forkId: string;
|
||||
title: string;
|
||||
context: string;
|
||||
options: { id: string; text: string; recommended: boolean }[];
|
||||
/** The recommendation resolved to its option's text; absent if it names no option. */
|
||||
recommended?: string;
|
||||
depends: string[];
|
||||
/** In admission order, as served. */
|
||||
answers: AnswerRow[];
|
||||
};
|
||||
|
||||
export type Desk = {
|
||||
/** False until the list first arrives: "not published yet", never "no questions". */
|
||||
published: boolean;
|
||||
/** Oldest first, by the card's admission ordinal. */
|
||||
open: DeskRow[];
|
||||
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */
|
||||
recorded: { forkId: string; title: string; rulingId: string }[];
|
||||
/** The list's own open_count: the one count the navigator, the status line and the booth share. */
|
||||
counts: { open: number };
|
||||
};
|
||||
|
||||
const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
|
||||
answerId: a.answer_id,
|
||||
outcome: a.outcome,
|
||||
choice:
|
||||
a.choice === null
|
||||
? undefined
|
||||
: entry.card.options.find((o) => o.id === a.choice)?.text ?? a.choice,
|
||||
note: a.note ?? undefined,
|
||||
target: a.of ?? a.retracts ?? undefined,
|
||||
code: a.code ?? undefined,
|
||||
at: a.answered_at,
|
||||
counted: entry.record.counted_answer_id === a.answer_id,
|
||||
});
|
||||
|
||||
export const buildDesk = (
|
||||
list: ForkList | undefined,
|
||||
unverified: ReadonlySet<string> = new Set()
|
||||
): Desk => {
|
||||
if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } };
|
||||
const open: DeskRow[] = [];
|
||||
const recorded: Desk['recorded'] = [];
|
||||
list.forks.forEach((entry) => {
|
||||
const { card, record } = entry;
|
||||
if (card.status === 'closed') {
|
||||
if (card.ruling !== null)
|
||||
recorded.push({ forkId: card.fork_id, title: card.title, rulingId: card.ruling });
|
||||
return;
|
||||
}
|
||||
if (unverified.has(card.fork_id)) {
|
||||
open.push({ kind: 'unverified', forkId: card.fork_id });
|
||||
return;
|
||||
}
|
||||
open.push({
|
||||
kind: 'question',
|
||||
forkId: card.fork_id,
|
||||
title: card.title,
|
||||
context: card.context,
|
||||
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
|
||||
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
|
||||
depends: card.depends,
|
||||
answers: record.answers.map((a) => answerRow(a, entry)),
|
||||
});
|
||||
});
|
||||
return {
|
||||
published: true,
|
||||
open,
|
||||
recorded,
|
||||
counts: { open: list.open_count },
|
||||
};
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { MINDS, resolvePose } from '../../components/message/pose/poses';
|
||||
import { MOOD_POSE, deskMood } from './deskMood';
|
||||
|
||||
describe('deskMood', () => {
|
||||
it('is neutral with the desk clear when nothing is open', () => {
|
||||
expect(deskMood(0, 0)).toEqual({
|
||||
mood: 'neutral',
|
||||
pose: 'neutral',
|
||||
line: "Desk's clear. I'll wave when something lands.",
|
||||
});
|
||||
});
|
||||
it('is triumph when every open fork is answered', () => {
|
||||
expect(deskMood(3, 3).mood).toBe('triumph');
|
||||
expect(deskMood(1, 1).mood).toBe('triumph');
|
||||
});
|
||||
it('is thinking when none is answered, and says whether it is one fork or several', () => {
|
||||
expect(deskMood(1, 0)).toMatchObject({ mood: 'thinking', line: 'Just one fork on the desk.' });
|
||||
expect(deskMood(4, 0)).toMatchObject({
|
||||
mood: 'thinking',
|
||||
line: '4 forks on the desk. No rush.',
|
||||
});
|
||||
});
|
||||
it('is determined partway, counting what is left', () => {
|
||||
expect(deskMood(5, 2)).toMatchObject({ mood: 'determined', line: '3 to go.' });
|
||||
});
|
||||
it('is happy when the pick is the recommendation', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'B', recommended: 'B' }).mood).toBe('happy');
|
||||
});
|
||||
it('is surprised, and asks for a line on why, when the pick differs', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'A', recommended: 'B' })).toMatchObject({
|
||||
mood: 'surprised',
|
||||
line: expect.stringContaining('A line in the note on why helps'),
|
||||
});
|
||||
});
|
||||
it('is surprised, not happy, when there is no recommendation to match', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'A' }).mood).toBe('surprised');
|
||||
});
|
||||
it('is note when a pick is cleared, saying the note can carry the answer', () => {
|
||||
expect(deskMood(2, 1, { type: 'cleared' })).toMatchObject({
|
||||
mood: 'note',
|
||||
line: expect.stringContaining('note can carry the answer on its own'),
|
||||
});
|
||||
});
|
||||
it('is concerned when a send failed, even with everything answered', () => {
|
||||
expect(deskMood(2, 2, { type: 'sendFailed' }).mood).toBe('concerned');
|
||||
});
|
||||
it('is surprised when a new fork arrives, even on a clear desk', () => {
|
||||
expect(deskMood(0, 0, { type: 'newFork' })).toMatchObject({
|
||||
mood: 'surprised',
|
||||
line: 'New fork on the desk!',
|
||||
});
|
||||
});
|
||||
it('maps every mood to a label every Mind in the catalogue can show', () => {
|
||||
Object.values(MOOD_POSE).forEach((pose) =>
|
||||
MINDS.forEach((mind) => expect(resolvePose(mind, pose)).toBe(pose))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/**
|
||||
* The booth Persona's reactions, from the original Fork Desk's mood() and choose() rules. A pure function of
|
||||
* what is on the desk and the last thing that happened; it is the desk's character, so each rule is tested.
|
||||
*/
|
||||
|
||||
/** The original's nine moods. */
|
||||
export type Mood =
|
||||
| 'idle'
|
||||
| 'neutral'
|
||||
| 'thinking'
|
||||
| 'happy'
|
||||
| 'surprised'
|
||||
| 'note'
|
||||
| 'determined'
|
||||
| 'triumph'
|
||||
| 'concerned';
|
||||
|
||||
/** What last happened; `none` means nothing since the page opened or the last event wore off. */
|
||||
export type DeskEvent =
|
||||
| { type: 'none' }
|
||||
| { type: 'picked'; choice: string; recommended?: string }
|
||||
| { type: 'cleared' }
|
||||
| { type: 'sendFailed' }
|
||||
| { type: 'newFork' };
|
||||
|
||||
/**
|
||||
* Each mood on the shared cast-art labels (neutral, happy, amused, thinking, annoyed, surprised) that every
|
||||
* Mind has, so the booth never asks a Mind for art it lacks.
|
||||
*/
|
||||
export const MOOD_POSE: Record<Mood, string> = {
|
||||
idle: 'neutral',
|
||||
neutral: 'neutral',
|
||||
thinking: 'thinking',
|
||||
happy: 'happy',
|
||||
surprised: 'surprised',
|
||||
note: 'thinking',
|
||||
determined: 'thinking',
|
||||
triumph: 'happy',
|
||||
concerned: 'surprised',
|
||||
};
|
||||
|
||||
export type DeskMood = { mood: Mood; pose: string; line: string };
|
||||
|
||||
const say = (mood: Mood, line: string): DeskMood => ({ mood, pose: MOOD_POSE[mood], line });
|
||||
|
||||
export const deskMood = (
|
||||
open: number,
|
||||
answered: number,
|
||||
event: DeskEvent = { type: 'none' }
|
||||
): DeskMood => {
|
||||
switch (event.type) {
|
||||
case 'sendFailed':
|
||||
return say('concerned', "That one didn't save. Give it another try in a moment?");
|
||||
case 'newFork':
|
||||
return say('surprised', 'New fork on the desk!');
|
||||
case 'cleared':
|
||||
return say('note', 'Pick cleared. Your note can carry the answer on its own.');
|
||||
case 'picked':
|
||||
return event.recommended !== undefined && event.choice === event.recommended
|
||||
? say('happy', "That's the one I'd have picked too.")
|
||||
: say('surprised', 'Off-script. A line in the note on why helps the next brief.');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (open === 0) return say('neutral', "Desk's clear. I'll wave when something lands.");
|
||||
if (answered >= open)
|
||||
return say('triumph', 'All answered! I will carry these back and admit them.');
|
||||
if (answered === 0) {
|
||||
return say(
|
||||
'thinking',
|
||||
open === 1 ? 'Just one fork on the desk.' : `${open} forks on the desk. No rush.`
|
||||
);
|
||||
}
|
||||
return say('determined', `${open - answered} to go.`);
|
||||
};
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
/** A card as the server writes it: every key present. The hash is a stand-in; tests pass `unverified` directly. */
|
||||
export const card = (label: string, over: Partial<ForkCard> = {}): ForkCard => ({
|
||||
schemaVersion: 'gamecult.fork.card.v0',
|
||||
fork_id: `c:question:${label}`,
|
||||
title: `Question ${label}`,
|
||||
context: `Context for ${label}.`,
|
||||
options: [
|
||||
{ id: 'A', text: 'First way' },
|
||||
{ id: 'B', text: 'Second way' },
|
||||
{ id: 'C', text: 'Third way' },
|
||||
],
|
||||
recommendation: 'B',
|
||||
depends: [],
|
||||
leans: null,
|
||||
flag: null,
|
||||
raised_in: null,
|
||||
deciders: ['d1'],
|
||||
status: 'open',
|
||||
ruling: null,
|
||||
ruled_from: [],
|
||||
order: 1,
|
||||
options_hash: `hash-${label}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const answer = (label: string, n: number, over: Partial<DeskAnswer> = {}): DeskAnswer => ({
|
||||
schemaVersion: 'gamecult.desk_answer.v0',
|
||||
answer_id: `n${n}`,
|
||||
fork_id: `c:question:${label}`,
|
||||
outcome: 'Counted',
|
||||
of: null,
|
||||
retracts: null,
|
||||
code: null,
|
||||
options_hash: `hash-${label}`,
|
||||
choice: 'A',
|
||||
note: null,
|
||||
answered_at: '2026-11-09T10:00:00Z',
|
||||
received_at: '2026-11-09T10:00:01Z',
|
||||
claim_in_future: false,
|
||||
answerer: 'acct-1',
|
||||
authority: 'Member',
|
||||
request_id: `r${n}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const entry = (
|
||||
label: string,
|
||||
over: Partial<ForkCard> = {},
|
||||
answers: DeskAnswer[] = [],
|
||||
counted: string | null = null
|
||||
): ForkEntry => {
|
||||
const c = card(label, over);
|
||||
return {
|
||||
schemaVersion: 'gamecult.fork.entry.v0',
|
||||
card: c,
|
||||
record: {
|
||||
schemaVersion: 'gamecult.fork.record.v0',
|
||||
fork_id: c.fork_id,
|
||||
options_hash: c.options_hash,
|
||||
counted_answer_id: counted,
|
||||
answers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const list = (forks: ForkEntry[], openCount?: number): ForkList => ({
|
||||
schemaVersion: 'gamecult.fork.list.v0',
|
||||
mind: 'mind',
|
||||
stamp: 's12',
|
||||
open_count: openCount ?? forks.filter((f) => f.card.status === 'open').length,
|
||||
recorded_count: forks.filter((f) => f.card.status === 'closed').length,
|
||||
forks,
|
||||
next_cursor: null,
|
||||
});
|
||||
|
||||
/** One list per state the desk shows. */
|
||||
export const STATES = {
|
||||
empty: list([]),
|
||||
noAnswers: list([entry('plain')]),
|
||||
blocked: list([
|
||||
entry('first', { order: 1 }),
|
||||
entry('second', { order: 2, depends: ['Needs the first one settled'] }),
|
||||
]),
|
||||
settled: list([
|
||||
entry('done', { status: 'closed', ruling: 'ruling:done-1', ruled_from: ['n1'] }),
|
||||
entry('shut', { status: 'closed' }),
|
||||
entry('live'),
|
||||
]),
|
||||
answered: list([
|
||||
entry(
|
||||
'plain',
|
||||
{},
|
||||
[
|
||||
answer('plain', 1, { note: 'Because it is first.' }),
|
||||
answer('plain', 2, {
|
||||
outcome: 'Revision',
|
||||
of: 'n1',
|
||||
choice: 'C',
|
||||
note: null,
|
||||
answerer: 'acct-2',
|
||||
}),
|
||||
answer('plain', 3, { outcome: 'Retraction', retracts: 'n2', choice: null }),
|
||||
answer('plain', 4, {
|
||||
outcome: 'Refused',
|
||||
code: 'not_a_decider',
|
||||
choice: 'B',
|
||||
note: 'Let me in.',
|
||||
}),
|
||||
answer('plain', 5, { choice: null, note: 'Either, if measured first.' }),
|
||||
],
|
||||
'n1'
|
||||
),
|
||||
]),
|
||||
} satisfies Record<string, ForkList>;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* The desk's typed view of the documents Bifrost serves for a Mind's desk: CultLib's
|
||||
* contracts/cultnet/governance/fork-card.md (gamecult.fork.list.v0 / entry / card / record, and the stored
|
||||
* gamecult.desk_answer.v0). Read-only: the page decodes them as served and derives nothing from them (the counted
|
||||
* answer is the record's `counted_answer_id`, not the page's opinion), so nothing here has a write shape.
|
||||
* Every key is written by the server, nil where absent.
|
||||
*/
|
||||
|
||||
export type ForkOption = { id: string; text: string };
|
||||
|
||||
export type ForkCard = {
|
||||
schemaVersion: 'gamecult.fork.card.v0';
|
||||
/** `<campaign>:question:<label>`. */
|
||||
fork_id: string;
|
||||
title: string;
|
||||
context: string;
|
||||
options: ForkOption[];
|
||||
/** An option id; never nil. Never preselected. */
|
||||
recommendation: string;
|
||||
/** Prose lines, each verbatim; a line may read like a reference but is only text. */
|
||||
depends: string[];
|
||||
/** An option id, or nil. Not shown by the desk. */
|
||||
leans: string | null;
|
||||
/** Not shown by the desk. */
|
||||
flag: string | null;
|
||||
raised_in: { kind: string; id: string } | null;
|
||||
deciders: string[];
|
||||
status: 'open' | 'closed';
|
||||
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
|
||||
ruling: string | null;
|
||||
ruled_from: string[];
|
||||
/** The admission ordinal. */
|
||||
order: number;
|
||||
/**
|
||||
* Publisher's hash of the five shown members. The desk recomputes it (optionsHash.ts, pinned to the CultLib
|
||||
* vectors) and withdraws a card whose hash differs.
|
||||
*/
|
||||
options_hash: string;
|
||||
};
|
||||
|
||||
/** One stored answer, told apart by `outcome` (and `code` for a refusal). */
|
||||
export type DeskAnswer = {
|
||||
schemaVersion: 'gamecult.desk_answer.v0';
|
||||
/** `n<seq>`. */
|
||||
answer_id: string;
|
||||
fork_id: string;
|
||||
outcome: 'Counted' | 'Revision' | 'Retraction' | 'Refused';
|
||||
/** A Revision's target. */
|
||||
of: string | null;
|
||||
/** A Retraction's target (or a refused retraction's). */
|
||||
retracts: string | null;
|
||||
/** A refusal's code. */
|
||||
code: string | null;
|
||||
options_hash: string;
|
||||
choice: string | null;
|
||||
/** The decider's own words, verbatim; nil for everyone Bifrost does not show notes to. */
|
||||
note: string | null;
|
||||
answered_at: string;
|
||||
received_at: string;
|
||||
claim_in_future: boolean;
|
||||
answerer: string;
|
||||
authority: string;
|
||||
request_id: string;
|
||||
};
|
||||
|
||||
export type ForkRecord = {
|
||||
schemaVersion: 'gamecult.fork.record.v0';
|
||||
fork_id: string;
|
||||
options_hash: string;
|
||||
/** Derived by the receiver; the page renders it and never recomputes it. */
|
||||
counted_answer_id: string | null;
|
||||
/** In admission order. */
|
||||
answers: DeskAnswer[];
|
||||
};
|
||||
|
||||
export type ForkEntry = {
|
||||
schemaVersion: 'gamecult.fork.entry.v0';
|
||||
card: ForkCard;
|
||||
record: ForkRecord;
|
||||
};
|
||||
|
||||
/** What the page holds for one Mind's desk. */
|
||||
export type ForkList = {
|
||||
schemaVersion: 'gamecult.fork.list.v0';
|
||||
mind: string;
|
||||
/** `s<N>`: the same stamp is the same state. */
|
||||
stamp: string;
|
||||
open_count: number;
|
||||
recorded_count: number;
|
||||
forks: ForkEntry[];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { Desk } from './Desk';
|
||||
export { DeskEntry } from './DeskEntry';
|
||||
export { setDeskSource } from './source';
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import vectors from './optionsHashVectors.json';
|
||||
import { entry } from './fixtures';
|
||||
import { ForkCard } from './forkContract';
|
||||
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
|
||||
|
||||
describe('options_hash against the CultLib vectors', () => {
|
||||
it('has all fifteen vectors', () => {
|
||||
expect(vectors.optionsHash).toHaveLength(15);
|
||||
});
|
||||
vectors.optionsHash.forEach((v) => {
|
||||
it(`${v.name}: canonical bytes and sha256 match`, async () => {
|
||||
expect(canonicalShown(v)).toBe(v.canonical);
|
||||
expect(await optionsHash(v)).toBe(v.sha256);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unverifiedForks', () => {
|
||||
const sealed = async (over: Partial<ForkCard> = {}) => {
|
||||
const e = entry('plain', over);
|
||||
return { ...e, card: { ...e.card, options_hash: await optionsHash(shownOf(e.card)) } };
|
||||
};
|
||||
it('passes a card whose hash is the hash of what it shows', async () => {
|
||||
expect(await unverifiedForks([await sealed()])).toEqual(new Set());
|
||||
});
|
||||
it('flags a card whose shown title, context, option, recommendation or depends changed after hashing', async () => {
|
||||
const e = await sealed();
|
||||
const edits: Partial<ForkCard>[] = [
|
||||
{ title: 'Other' },
|
||||
{ context: 'Other context.' },
|
||||
{ options: [...e.card.options].reverse() },
|
||||
{ recommendation: 'A' },
|
||||
{ depends: ['new line'] },
|
||||
];
|
||||
await Promise.all(
|
||||
edits.map(async (x) =>
|
||||
expect(
|
||||
await unverifiedForks([{ ...e, card: { ...e.card, ...x } }]),
|
||||
JSON.stringify(Object.keys(x))
|
||||
).toEqual(new Set([e.card.fork_id]))
|
||||
)
|
||||
);
|
||||
});
|
||||
it('does not read fork_id, status, order, leans or flag, which are not shown members', async () => {
|
||||
const e = await sealed();
|
||||
const card = { ...e.card, fork_id: 'x:question:y', order: 7, leans: 'A', flag: 'f' };
|
||||
expect(await unverifiedForks([{ ...e, card }])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { ForkCard, ForkEntry } from './forkContract';
|
||||
|
||||
// The hash and its canonical bytes are specified in CultLib contracts/cultnet/governance/fork-card.md
|
||||
// ("Options hash", rule owner: Heimdall's binding file 1.2); the vectors in optionsHashVectors.json pin this code to it.
|
||||
|
||||
const jstr = (s: string): string => {
|
||||
let out = '"';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const ch of s.normalize('NFC')) {
|
||||
const o = ch.codePointAt(0) as number;
|
||||
const named: Record<number, string> = { 8: '\\b', 9: '\\t', 10: '\\n', 12: '\\f', 13: '\\r' };
|
||||
if (ch === '"') out += '\\"';
|
||||
else if (ch === '\\') out += '\\\\';
|
||||
else if (named[o]) out += named[o];
|
||||
else out += o < 0x20 ? `\\u${o.toString(16).padStart(4, '0')}` : ch;
|
||||
}
|
||||
return `${out}"`;
|
||||
};
|
||||
|
||||
export type Shown = Pick<ForkCard, 'title' | 'context' | 'options' | 'recommendation' | 'depends'>;
|
||||
|
||||
/** The five shown members as RFC 8785 JSON, keys already in sorted order. */
|
||||
export const canonicalShown = (c: Shown): string =>
|
||||
`{"context":${jstr(c.context)},"depends":[${c.depends.map(jstr).join(',')}],"options":[${c.options
|
||||
.map((o) => `{"id":${jstr(o.id)},"text":${jstr(o.text)}}`)
|
||||
.join(',')}],"recommendation":${jstr(c.recommendation)},"title":${jstr(c.title)}}`;
|
||||
|
||||
export const shownOf = (c: ForkCard): Shown => ({
|
||||
title: c.title,
|
||||
context: c.context,
|
||||
options: c.options,
|
||||
recommendation: c.recommendation,
|
||||
depends: c.depends,
|
||||
});
|
||||
|
||||
export const optionsHash = async (c: Shown): Promise<string> => {
|
||||
const bytes = new TextEncoder().encode(canonicalShown(c));
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
/** Fork ids whose published `options_hash` is not the hash of what the card shows. */
|
||||
export const unverifiedForks = async (forks: ForkEntry[]): Promise<Set<string>> => {
|
||||
const bad = new Set<string>();
|
||||
await Promise.all(
|
||||
forks.map(async ({ card }) => {
|
||||
if ((await optionsHash(shownOf(card))) !== card.options_hash) bad.add(card.fork_id);
|
||||
})
|
||||
);
|
||||
return bad;
|
||||
};
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
{
|
||||
"source": "GameCult/CultLib contracts/cultnet/governance/fork-card-vectors.json, `optionsHash` member only, values unchanged (re-escaped to ASCII so TypeScript parses it), checked equal at CultLib 0c19eddb. Rule: contracts/cultnet/governance/fork-card.md, 'Options hash'.",
|
||||
"optionsHash": [
|
||||
{
|
||||
"name": "heimdall_A_five_members",
|
||||
"heimdall": "A",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_B_recommendation_changed",
|
||||
"heimdall": "B",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_C_option_order_changed",
|
||||
"heimdall": "C",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
},
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_D_nfc_escapes_astral",
|
||||
"heimdall": "D",
|
||||
"title": "Caf\u00e9 \u201cdesk\u201d",
|
||||
"context": "Line one\nLine two \u2014 \ud83d\ude00",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "x"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "y"
|
||||
}
|
||||
],
|
||||
"recommendation": "a",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
|
||||
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_E_decomposed_equals_D",
|
||||
"heimdall": "E",
|
||||
"title": "Cafe\u0301 \u201cdesk\u201d",
|
||||
"context": "Line one\nLine two \u2014 \ud83d\ude00",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "x"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "y"
|
||||
}
|
||||
],
|
||||
"recommendation": "a",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
|
||||
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_F_option_id_changed",
|
||||
"heimdall": "F",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "share",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_G_depends_emptied",
|
||||
"heimdall": "G",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"
|
||||
},
|
||||
{
|
||||
"name": "option_text_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it!"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6"
|
||||
},
|
||||
{
|
||||
"name": "title_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent?",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D",
|
||||
"sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f"
|
||||
},
|
||||
{
|
||||
"name": "context_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should slot marks be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173"
|
||||
},
|
||||
{
|
||||
"name": "depends_order_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience", "x:question:y"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d"
|
||||
},
|
||||
{
|
||||
"name": "depends_order_changed_reversed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["x:question:y", "gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796"
|
||||
},
|
||||
{
|
||||
"name": "hidden_members_do_not_move_it",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
|
||||
},
|
||||
{
|
||||
"name": "control_characters_and_markup_are_escaped_minimally",
|
||||
"heimdall": null,
|
||||
"title": "q \" b \\ t \t n \n bell \u0007 del \u007f",
|
||||
"context": "</script> \u2028 &",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"</script> \u2028 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \u007f\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D",
|
||||
"sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe"
|
||||
},
|
||||
{
|
||||
"name": "astral_option_id_and_text",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "\ud83d\ude00",
|
||||
"text": "\ud834\udd1e"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"\ud83d\ude00\",\"text\":\"\ud834\udd1e\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { ForkList } from './forkContract';
|
||||
|
||||
type DeskSource = (mind: string) => ForkList | undefined;
|
||||
|
||||
// Slice 1 reads no network. The preview demo installs seeded forks here; slice 2 replaces this with
|
||||
// Bifrost's list reader, which is why the page asks a function and not a fixture.
|
||||
let source: DeskSource = () => undefined;
|
||||
|
||||
export const setDeskSource = (next: DeskSource) => {
|
||||
source = next;
|
||||
};
|
||||
|
||||
export const deskFor = (mind: string): ForkList | undefined => source(mind);
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DING_VOLUME, createDingPlayer, motifFor, parseManifest, pickDing } from './dings';
|
||||
|
||||
const manifest = parseManifest({
|
||||
neutral: { mention: 'neutral/mention.ogg', message: 'neutral/message.ogg' },
|
||||
senders: {
|
||||
eureka: { mention: 'eureka/mention.ogg', message: 'eureka/message.ogg', dm: 'eureka/dm.ogg' },
|
||||
nibu: { message: 'nibu/message.ogg' },
|
||||
},
|
||||
})!;
|
||||
|
||||
describe('dings manifest', () => {
|
||||
it('is read defensively: junk is no manifest, bad paths are dropped', () => {
|
||||
expect(parseManifest(undefined)).toBeUndefined();
|
||||
expect(parseManifest('x')).toBeUndefined();
|
||||
expect(parseManifest({ neutral: 3, senders: [] })).toBeUndefined();
|
||||
const m = parseManifest({
|
||||
neutral: { message: '../../etc/passwd.ogg', mention: 'https://evil.example/a.ogg' },
|
||||
senders: { void: { message: 'void/message.wav', dm: 'void/dm.ogg', mention: '/abs.ogg' } },
|
||||
})!;
|
||||
expect(m.neutral).toEqual({});
|
||||
expect(m.senders.void).toEqual({ dm: 'void/dm.ogg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest guards', () => {
|
||||
it('rejects a dot segment in the middle of a path', () => {
|
||||
const m = parseManifest({
|
||||
neutral: { message: 'a/../b.ogg', mention: 'a/b.ogg' },
|
||||
senders: {},
|
||||
})!;
|
||||
expect(m.neutral).toEqual({ mention: 'a/b.ogg' });
|
||||
});
|
||||
|
||||
it('is not fooled by an array of senders', () => {
|
||||
expect(parseManifest({ neutral: { message: 'a.ogg' }, senders: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores motifs a clip set only inherits', () => {
|
||||
const inherited = Object.create({ message: 'x/message.ogg' });
|
||||
const m = parseManifest({ neutral: inherited, senders: {} })!;
|
||||
expect(m.neutral).toEqual({});
|
||||
});
|
||||
|
||||
it('gives object-prototype names no clip of their own', () => {
|
||||
const m = parseManifest({ neutral: { message: 'n/message.ogg' }, senders: {} })!;
|
||||
['__proto__', 'constructor', 'toString', 'hasOwnProperty'].forEach((who) => {
|
||||
expect(pickDing(m, who, 'message')?.path).toBe('n/message.ogg');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the motif volume in the pick itself', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'message')?.volume).toBe(DING_VOLUME.message);
|
||||
expect(pickDing(manifest, 'stranger', 'mention')?.volume).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which ding plays', () => {
|
||||
it('uses the sender instrument for the motif', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'mention')).toEqual({
|
||||
path: 'eureka/mention.ogg',
|
||||
volume: DING_VOLUME.mention,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the neutral ding for an unknown sender', () => {
|
||||
expect(pickDing(manifest, 'stranger', 'message')?.path).toBe('neutral/message.ogg');
|
||||
});
|
||||
|
||||
it('falls back to neutral when the sender has no clip for that motif', () => {
|
||||
expect(pickDing(manifest, 'nibu', 'mention')?.path).toBe('neutral/mention.ogg');
|
||||
});
|
||||
|
||||
it('is silent when neither the sender nor neutral has a clip', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'invite')).toBeUndefined();
|
||||
expect(pickDing(manifest, 'stranger', 'dm')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is loud for mentions and DMs and quiet for ordinary messages', () => {
|
||||
expect(DING_VOLUME.mention).toBe(1);
|
||||
expect(DING_VOLUME.dm).toBe(1);
|
||||
expect(DING_VOLUME.message).toBeLessThan(DING_VOLUME.invite);
|
||||
expect(DING_VOLUME.invite).toBeLessThan(DING_VOLUME.mention);
|
||||
});
|
||||
|
||||
it('names the motif: invite over mention over DM over message', () => {
|
||||
expect(motifFor({ invite: true, highlight: true, direct: true })).toBe('invite');
|
||||
expect(motifFor({ highlight: true, direct: true })).toBe('mention');
|
||||
expect(motifFor({ highlight: false, direct: true })).toBe('dm');
|
||||
expect(motifFor({ highlight: false, direct: false })).toBe('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio unlock', () => {
|
||||
const make = () => {
|
||||
const listeners = new Map<string, () => void>();
|
||||
const target = {
|
||||
addEventListener: (t: string, f: () => void) => listeners.set(t, f),
|
||||
removeEventListener: (t: string) => listeners.delete(t),
|
||||
};
|
||||
const played: Array<{ url: string; volume: number }> = [];
|
||||
const player = createDingPlayer((url) => {
|
||||
const clip = { volume: 1, play: vi.fn(async () => undefined) };
|
||||
played.push({
|
||||
url,
|
||||
get volume() {
|
||||
return clip.volume;
|
||||
},
|
||||
} as never);
|
||||
return clip;
|
||||
}, target);
|
||||
return { listeners, player, played };
|
||||
};
|
||||
|
||||
it('does not play before the first user gesture, and plays after it', () => {
|
||||
const { listeners, player, played } = make();
|
||||
expect(player.play('a.ogg', 1)).toBe(false);
|
||||
expect(played).toHaveLength(0);
|
||||
listeners.get('pointerdown')!();
|
||||
expect(player.play('a.ogg', 0.3)).toBe(true);
|
||||
expect(played).toHaveLength(1);
|
||||
expect(played[0].volume).toBe(0.3);
|
||||
});
|
||||
|
||||
it('stops listening once unlocked', () => {
|
||||
const { listeners, player } = make();
|
||||
listeners.get('keydown')!();
|
||||
expect(listeners.size).toBe(0);
|
||||
expect(player.unlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('a clip the browser refuses does not throw', async () => {
|
||||
const target = {
|
||||
addEventListener: (_t: string, f: () => void) => f(),
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const player = createDingPlayer(
|
||||
() => ({ volume: 1, play: () => Promise.reject(new Error('NotAllowedError')) }),
|
||||
target
|
||||
);
|
||||
expect(() => player.play('a.ogg', 1)).not.toThrow();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
// Dings: pre-rendered earcons shipped as static assets, `dings/<mxid-localpart>/<motif>.ogg` plus
|
||||
// `dings/manifest.json` (docs/gamecult/ratatoskr-target.md, Agreements). The sender picks the
|
||||
// instrument, the event picks the motif. This file is the whole decision; the client only asks it.
|
||||
|
||||
export type Motif = 'mention' | 'dm' | 'message' | 'invite';
|
||||
type Clips = Partial<Record<Motif, string>>;
|
||||
export type DingManifest = { neutral: Clips; senders: Record<string, Clips> };
|
||||
|
||||
/** Client config `dings`. Off unless `enabled` is exactly true; nothing about dings loads otherwise. */
|
||||
export type DingsConfig = { enabled?: boolean };
|
||||
export const dingsEnabled = (config: DingsConfig | undefined): boolean => config?.enabled === true;
|
||||
|
||||
const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite'];
|
||||
|
||||
// Mentions and DMs loud, an ordinary message quiet.
|
||||
export const DING_VOLUME: Record<Motif, number> = {
|
||||
mention: 1,
|
||||
dm: 1,
|
||||
invite: 0.7,
|
||||
message: 0.3,
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
// A clip path stays inside dings/: relative, ogg or mp3 (not wav), no dot segments, no scheme.
|
||||
const isClipPath = (v: unknown): v is string =>
|
||||
typeof v === 'string' &&
|
||||
/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_.-]+)*\.(ogg|mp3)$/.test(v) &&
|
||||
!v.split('/').includes('..');
|
||||
|
||||
const readClips = (v: unknown): Clips => {
|
||||
const clips: Clips = {};
|
||||
if (!isRecord(v)) return clips;
|
||||
MOTIFS.forEach((m) => {
|
||||
if (Object.prototype.hasOwnProperty.call(v, m) && isClipPath(v[m])) clips[m] = v[m] as string;
|
||||
});
|
||||
return clips;
|
||||
};
|
||||
|
||||
/** The manifest is JSON from outside the typed world: read it defensively, undefined if unusable. */
|
||||
export const parseManifest = (json: unknown): DingManifest | undefined => {
|
||||
if (!isRecord(json) || !isRecord(json.neutral) || !isRecord(json.senders)) return undefined;
|
||||
const senders: Record<string, Clips> = Object.create(null);
|
||||
Object.keys(json.senders).forEach((localpart) => {
|
||||
senders[localpart] = readClips(
|
||||
json.senders && (json.senders as Record<string, unknown>)[localpart]
|
||||
);
|
||||
});
|
||||
return { neutral: readClips(json.neutral), senders };
|
||||
};
|
||||
|
||||
/** The sender's own clip, else the neutral one, else nothing: silence. */
|
||||
export const pickDing = (
|
||||
manifest: DingManifest,
|
||||
localpart: string,
|
||||
motif: Motif
|
||||
): { path: string; volume: number } | undefined => {
|
||||
const own = Object.prototype.hasOwnProperty.call(manifest.senders, localpart)
|
||||
? manifest.senders[localpart][motif]
|
||||
: undefined;
|
||||
const path = own ?? manifest.neutral[motif];
|
||||
return path === undefined ? undefined : { path, volume: DING_VOLUME[motif] };
|
||||
};
|
||||
|
||||
export const motifFor = (e: { invite?: boolean; highlight: boolean; direct: boolean }): Motif => {
|
||||
if (e.invite) return 'invite';
|
||||
if (e.highlight) return 'mention';
|
||||
return e.direct ? 'dm' : 'message';
|
||||
};
|
||||
|
||||
type Clip = { volume: number; play: () => Promise<void> };
|
||||
type GestureTarget = {
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
};
|
||||
const GESTURES = ['pointerdown', 'keydown', 'touchend'];
|
||||
|
||||
/**
|
||||
* Browsers refuse audio until the first user gesture. The player stays silent until one has
|
||||
* happened, then plays; a clip the browser still refuses is dropped, never thrown.
|
||||
*/
|
||||
export const createDingPlayer = (make: (url: string) => Clip, target: GestureTarget) => {
|
||||
let unlocked = false;
|
||||
const unlock = () => {
|
||||
unlocked = true;
|
||||
GESTURES.forEach((g) => target.removeEventListener(g, unlock));
|
||||
};
|
||||
GESTURES.forEach((g) => target.addEventListener(g, unlock));
|
||||
return {
|
||||
unlocked: () => unlocked,
|
||||
play(url: string, volume: number): boolean {
|
||||
if (!unlocked) return false;
|
||||
const clip = make(url);
|
||||
clip.volume = volume;
|
||||
clip.play().catch(() => undefined);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DingManifest, Motif, dingsEnabled, parseManifest, pickDing } from './dings';
|
||||
|
||||
// The staged v4 set (Aqua, AquaSynth merge 21e83ec, page minds.gamecult.org/aqua/dings-voices-dd9be66). Nobody has heard it; these tests check the manifest
|
||||
// against this code, not the sound.
|
||||
const DINGS = join(__dirname, '../../../../public/dings');
|
||||
const raw = JSON.parse(readFileSync(join(DINGS, 'manifest.json'), 'utf8'));
|
||||
const manifest = parseManifest(raw) as DingManifest;
|
||||
// sha256 prefixes of the 32 clips as staged by Aqua; Nibu's four are proposals/nibu3 (same samples, own Ogg serial).
|
||||
const V4_SHA: Record<string, string> = {
|
||||
'aqua/mention': 'cedb63552008',
|
||||
'aqua/dm': '5b996d01c770',
|
||||
'aqua/message': '8be19ccd505a',
|
||||
'aqua/invite': '462e63687f0b',
|
||||
'eureka/mention': '7afe0bcc90d0',
|
||||
'eureka/dm': '3909d70b0d45',
|
||||
'eureka/message': '0b466cd4b437',
|
||||
'eureka/invite': '837e1bbbdacf',
|
||||
'heimdall/mention': '36051569eb2c',
|
||||
'heimdall/dm': '26ebc4158d51',
|
||||
'heimdall/message': 'c383d6ce3da0',
|
||||
'heimdall/invite': 'fd47e9a97bb2',
|
||||
'kiko/mention': 'e73d2d586f4e',
|
||||
'kiko/dm': 'b3e39946ab70',
|
||||
'kiko/message': '6730b9df5bfa',
|
||||
'kiko/invite': '1a3c1d118b9e',
|
||||
'libby/mention': 'eb9f3ac60b02',
|
||||
'libby/dm': '77dfd01cc3ca',
|
||||
'libby/message': '257b84a114a2',
|
||||
'libby/invite': '8ef7e91100b8',
|
||||
'nibu/mention': '56484d731df3',
|
||||
'nibu/dm': '573f5db4de4b',
|
||||
'nibu/message': '30f6a5c9ce54',
|
||||
'nibu/invite': 'bb36c0975f35',
|
||||
'void/mention': 'dee69f4af562',
|
||||
'void/dm': '92e3ebbc0b29',
|
||||
'void/message': 'fadc5556b681',
|
||||
'void/invite': 'd602121389d8',
|
||||
'weksa/mention': 'db46a36ab3c5',
|
||||
'weksa/dm': 'efe993382a46',
|
||||
'weksa/message': '00e323635949',
|
||||
'weksa/invite': '9bf03a36758b',
|
||||
};
|
||||
const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite'];
|
||||
const VOICES = ['aqua', 'eureka', 'heimdall', 'kiko', 'libby', 'nibu', 'void', 'weksa'];
|
||||
|
||||
// Vorbis-in-Ogg duration: last page's granule position over the sample rate in the id header.
|
||||
const oggMs = (file: string): number => {
|
||||
const b = readFileSync(join(DINGS, file));
|
||||
const id = b.indexOf('\x01vorbis', 0, 'latin1');
|
||||
const rate = b.readUInt32LE(id + 12);
|
||||
const last = b.lastIndexOf('OggS', b.length, 'latin1');
|
||||
return (Number(b.readBigUInt64LE(last + 6)) / rate) * 1000;
|
||||
};
|
||||
|
||||
describe('dings switch', () => {
|
||||
it('is off unless the config says exactly true', () => {
|
||||
expect(dingsEnabled(undefined)).toBe(false);
|
||||
expect(dingsEnabled({})).toBe(false);
|
||||
expect(dingsEnabled({ enabled: false })).toBe(false);
|
||||
expect(dingsEnabled({ enabled: 'true' as unknown as boolean })).toBe(false);
|
||||
expect(dingsEnabled({ enabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('ships off in config.json', () => {
|
||||
const config = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8'));
|
||||
expect(config.dings).toEqual({ enabled: false });
|
||||
expect(dingsEnabled(config.dings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v4 manifest against the parser', () => {
|
||||
it('survives parseManifest whole: no path or motif is dropped', () => {
|
||||
expect(manifest.neutral).toEqual(raw.neutral);
|
||||
expect(Object.keys(manifest.senders).sort()).toEqual(VOICES);
|
||||
VOICES.forEach((v) => expect(manifest.senders[v]).toEqual(raw.senders[v]));
|
||||
MOTIFS.forEach((m) => expect(manifest.neutral[m]).toBeDefined());
|
||||
});
|
||||
|
||||
it('names a file that exists for every clip, and no file the manifest omits', () => {
|
||||
const paths = [
|
||||
...Object.values(manifest.neutral),
|
||||
...Object.values(manifest.senders).flatMap((c) => Object.values(c)),
|
||||
];
|
||||
expect(paths).toHaveLength(4 + VOICES.length * 4);
|
||||
paths.forEach((p) => expect(existsSync(join(DINGS, p as string)), p).toBe(true));
|
||||
});
|
||||
|
||||
it('maps a sender localpart to that sender folder, per motif', () => {
|
||||
VOICES.forEach((v) =>
|
||||
MOTIFS.forEach((m) => expect(pickDing(manifest, v, m)?.path).toBe(`senders/${v}/${m}.ogg`))
|
||||
);
|
||||
});
|
||||
|
||||
it('an unknown sender and an invite get the neutral clip', () => {
|
||||
MOTIFS.forEach((m) => expect(pickDing(manifest, 'someone', m)?.path).toBe(`neutral/${m}.ogg`));
|
||||
});
|
||||
|
||||
it('Nibu carries pick 3 (8 kHz 5-bit smoothed sine) for every event: senders/nibu, never the proposals folder', () => {
|
||||
MOTIFS.forEach((m) => {
|
||||
const path = pickDing(manifest, 'nibu', m)?.path as string;
|
||||
expect(path).toBe(`senders/nibu/${m}.ogg`);
|
||||
expect(path).not.toContain('proposals');
|
||||
});
|
||||
});
|
||||
|
||||
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
|
||||
const hash = (file: string) =>
|
||||
createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12);
|
||||
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
|
||||
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
|
||||
});
|
||||
|
||||
it('keeps the clip lengths the design assumes: message under 400 ms, nothing over 600 ms', () => {
|
||||
VOICES.forEach((v) => {
|
||||
expect(oggMs(`senders/${v}/message.ogg`)).toBeLessThan(400);
|
||||
MOTIFS.forEach((m) => expect(oggMs(`senders/${v}/${m}.ogg`)).toBeLessThan(601));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import {
|
||||
DingManifest,
|
||||
Motif,
|
||||
createDingPlayer,
|
||||
dingsEnabled,
|
||||
parseManifest,
|
||||
pickDing,
|
||||
} from './dings';
|
||||
|
||||
const DINGS_ROOT = `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/dings`;
|
||||
|
||||
/**
|
||||
* Plays a ding for a sender and motif. The manifest is read once at runtime. Returns false when
|
||||
* there is no manifest (nothing rendered yet), so the caller keeps the stock sound; once there is
|
||||
* one, a sender or motif with no clip is silence, as the design says, and the return is true.
|
||||
* The client config's `dings.enabled` is the switch (default off): off, nothing is fetched, no
|
||||
* gesture listener is added, and the return is always false, so the stock sound plays.
|
||||
*/
|
||||
export const useDings = () => {
|
||||
const enabled = dingsEnabled(useClientConfig().dings);
|
||||
const manifestRef = useRef<DingManifest>();
|
||||
const player = useMemo(
|
||||
() => (enabled ? createDingPlayer((url) => new Audio(url), window) : undefined),
|
||||
[enabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
let live = true;
|
||||
fetch(`${DINGS_ROOT}/manifest.json`)
|
||||
.then((r) => (r.ok ? r.json() : undefined))
|
||||
.then((json) => {
|
||||
if (live) manifestRef.current = parseManifest(json);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return useCallback(
|
||||
(sender: string, motif: Motif): boolean => {
|
||||
const manifest = manifestRef.current;
|
||||
if (!player || !manifest) return false;
|
||||
const ding = pickDing(manifest, getMxIdLocalPart(sender) ?? sender, motif);
|
||||
if (ding) player.play(`${DINGS_ROOT}/${ding.path}`, ding.volume);
|
||||
return true;
|
||||
},
|
||||
[player]
|
||||
);
|
||||
};
|
||||
|
|
@ -42,7 +42,6 @@ import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'
|
|||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
import { useEstate, useEstateRootId } from './useEstate';
|
||||
import * as css from './EstateNavigator.css';
|
||||
import { DeskEntry } from '../desk';
|
||||
import { SkyStateControl } from '../scene/SkyStateControl';
|
||||
|
||||
/** How long a tap waits for the client store to hold the room it just joined. */
|
||||
|
|
@ -136,11 +135,9 @@ type CollapsibleProps = {
|
|||
categoryId: string;
|
||||
label: string;
|
||||
rooms: EstateRoom[];
|
||||
/** Sits in the header beside the label, so a collapsed section still shows it. */
|
||||
trailing?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) {
|
||||
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
|
|
@ -164,7 +161,6 @@ function Collapsible({ categoryId, label, rooms, trailing, children }: Collapsib
|
|||
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
||||
{label}
|
||||
</RoomNavCategoryButton>
|
||||
{trailing}
|
||||
</NavCategoryHeader>
|
||||
{!closed && children}
|
||||
</NavCategory>
|
||||
|
|
@ -173,12 +169,7 @@ function Collapsible({ categoryId, label, rooms, trailing, children }: Collapsib
|
|||
|
||||
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
|
||||
return (
|
||||
<Collapsible
|
||||
categoryId={`${tierId}|${group.mind}`}
|
||||
label={group.label}
|
||||
rooms={group.rooms}
|
||||
trailing={<DeskEntry mind={group.mind} />}
|
||||
>
|
||||
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
|
||||
{group.rooms.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ESLint } from 'eslint';
|
||||
|
||||
// The product rule is one join path: a join is joinEstateRoom, which files the DM before it sends the join.
|
||||
// This pins that the lint config enforces it, not just that the code happens to comply today.
|
||||
const lint = async (filePath: string, code: string) => {
|
||||
const eslint = new ESLint({ cwd: process.cwd(), useEslintrc: true });
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
return result.messages.filter((m) => m.ruleId === 'no-restricted-syntax');
|
||||
};
|
||||
const JOIN = 'export const f = (mx: { joinRoom: (id: string) => void }) => mx.joinRoom("!a:b");\n';
|
||||
|
||||
describe('the one join path is enforced by lint', () => {
|
||||
it('rejects a joinRoom call anywhere but joinEstateRoom.ts', async () => {
|
||||
const found = await lint('src/app/features/estate/EstateNavigator.tsx', JOIN);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].message).toContain('joinEstateRoom');
|
||||
});
|
||||
|
||||
it('allows the call inside joinEstateRoom.ts itself', async () => {
|
||||
expect(await lint('src/app/features/estate/joinEstateRoom.ts', JOIN)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the Airbnb syntax bans in the same files', async () => {
|
||||
const found = await lint(
|
||||
'src/app/foo.ts',
|
||||
'export const g = (o: object) => { for (const k in o) { return k; } return 0; };\n'
|
||||
);
|
||||
expect(found.some((m) => m.message.includes('for..in'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { surfaceInk } from '../scene/sceneShell.css';
|
||||
import { skyLabelInk } from '../scene/skyLabelInk';
|
||||
|
||||
export const CategoryButton = style({
|
||||
flexGrow: 1,
|
||||
|
|
@ -16,7 +15,6 @@ export const CategoryButton = style({
|
|||
borderRadius: 0,
|
||||
},
|
||||
':root[data-scene] &&': {
|
||||
color: skyLabelInk('inherit'),
|
||||
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
|
||||
filter: `drop-shadow(0 0 3px ${surfaceInk})`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -123,8 +123,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useSkyInk, useTheme } from '../../hooks/useTheme';
|
||||
import { skyLabelInk } from '../scene/skyLabelInk';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
|
||||
|
|
@ -463,12 +462,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||
|
||||
const theme = useTheme();
|
||||
const skyInk = useSkyInk();
|
||||
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
||||
theme.kind,
|
||||
creatorsTag,
|
||||
powerLevelTags,
|
||||
skyInk
|
||||
powerLevelTags
|
||||
);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
|
@ -1677,9 +1674,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
||||
<Text as="span" size="L400" style={{ color: skyLabelInk(color.Surface.OnContainer) }}>
|
||||
New Messages
|
||||
</Text>
|
||||
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="L400">New Messages</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
</MessageBase>
|
||||
) : null;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { Room } from 'matrix-js-sdk';
|
|||
import React from 'react';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useSkyInk } from '../../hooks/useTheme';
|
||||
import { noticeInk } from '../scene/skyInk';
|
||||
import {
|
||||
ROOM_NOTICE_EVENT_TYPE,
|
||||
parseRoomNotice,
|
||||
|
|
@ -16,16 +14,12 @@ import {
|
|||
export function RoomTrainingLine({ room }: { room: Room }) {
|
||||
const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE);
|
||||
const { previewDemo } = useClientConfig();
|
||||
const skyInk = useSkyInk();
|
||||
const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo);
|
||||
if (!line) return null;
|
||||
return (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
padding: `${config.space.S100} 0`,
|
||||
color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
|
@ -23,10 +23,6 @@ import { useSetting } from '../../state/hooks/settings';
|
|||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { AlbumView } from '../albums/AlbumView';
|
||||
import * as albumCss from '../albums/AlbumView.css';
|
||||
import { useIsFileZone } from '../albums/useAlbums';
|
||||
import { ZoneView, zoneView } from '../albums/zoneView';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -68,12 +64,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
const room = useRoom();
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
const isZone = useIsFileZone(room);
|
||||
// A zone shows its albums; the timeline stays one tap away, and an event deep link opens it.
|
||||
const [choice, setChoice] = useState<{ roomId: string; view: ZoneView }>();
|
||||
const fileZone =
|
||||
isZone &&
|
||||
zoneView(choice && choice.roomId === roomId ? choice.view : undefined, eventId) === 'albums';
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
|
|
@ -88,7 +78,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (fileZone || editableActiveElement()) return;
|
||||
if (editableActiveElement()) return;
|
||||
const portalContainer = document.getElementById('portalContainer');
|
||||
if (portalContainer && portalContainer.children.length > 0) {
|
||||
return;
|
||||
|
|
@ -97,35 +87,13 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
ReactEditor.focus(editor);
|
||||
}
|
||||
},
|
||||
[editor, fileZone]
|
||||
[editor]
|
||||
)
|
||||
);
|
||||
|
||||
// The file zone is its own view: no timeline, no composer.
|
||||
if (fileZone) {
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<AlbumView
|
||||
key={roomId}
|
||||
room={room}
|
||||
onTimeline={() => setChoice({ roomId, view: 'timeline' })}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{isZone && !eventId && (
|
||||
<button
|
||||
type="button"
|
||||
className={albumCss.Back}
|
||||
onClick={() => setChoice({ roomId, view: 'albums' })}
|
||||
>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
</button>
|
||||
)}
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down
|
||||
// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was.
|
||||
export const HeaderTopic = style({
|
||||
'@media': {
|
||||
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
paddingTop: 9,
|
||||
paddingBottom: 17,
|
||||
marginTop: -9,
|
||||
marginBottom: -17,
|
||||
},
|
||||
},
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: config.opacity.P500,
|
||||
|
|
|
|||
|
|
@ -82,8 +82,6 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
|||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { useSkyInk } from '../../../hooks/useTheme';
|
||||
import { userColorInk } from '../../scene/skyInk';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
|
@ -725,7 +723,6 @@ export const Message = as<'div', MessageProps>(
|
|||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const skyInk = useSkyInk();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
|
||||
|
|
@ -746,8 +743,7 @@ export const Message = as<'div', MessageProps>(
|
|||
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
||||
: undefined;
|
||||
|
||||
const hashColor = skyInk ? userColorInk(senderId, skyInk) : colorMXID(senderId);
|
||||
const usernameColor = legacyUsernameColor ? hashColor : tagColor;
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
||||
|
||||
const headerJSX = !collapse && (
|
||||
<Box
|
||||
|
|
@ -1168,6 +1164,7 @@ export const Message = as<'div', MessageProps>(
|
|||
<BubbleLayout
|
||||
own={own}
|
||||
pose={mind ? poseJSX : undefined}
|
||||
alt={poseAlt}
|
||||
enterDelay={enterDelay}
|
||||
before={avatarJSX}
|
||||
header={headerJSX}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,13 @@
|
|||
config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still
|
||||
means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene.
|
||||
|
||||
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, onBake?, sky? })` returns `{ login, dispose }` or
|
||||
null (tier `off`, no 2D context). Each of four ridge layers is a function of world-x (`ridge/ridgeWorld.ts` `profile`:
|
||||
ridged multifractal value noise, each octave folded at its crest and weighted by the previous one, domain-warped; one
|
||||
function, a parameter row per layer; nothing repeats). World-x is baked in chunks (1.2 viewport widths, integer pixels)
|
||||
onto plain 2D canvases over a baked sky gradient on the host canvas. Per layer a track holds three chunk canvases: two
|
||||
the window can reach and one ahead. The first view bakes only the first chunk of each layer; the others are baked in
|
||||
tasks of about 6 ms (`setTimeout`; a chunk is baked, put on a canvas with its in-scatter, and blurred in column bands, each sized from the last band's time). Per frame the only motion is the
|
||||
compositor sliding the track with a CSS transform (a Web Animation of `translate3d`, one chunk long, `fill: forwards`,
|
||||
150-420 s per viewport width, farther is slower); when it finishes the chunks move one slot left and the track restarts
|
||||
at 0 in the same task (no jump), and the canvas that left is baked with the next stretch of world. A second animation
|
||||
sways the whole rig (up to 5% of the width sideways, 3% of the height down, 150 s loop): the slow yaw and pitch.
|
||||
No script runs per frame. The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
|
||||
- `ridge/ridgeBake.ts` is the pixel function (pure, no DOM): per column the crest's slope gives a normal, lit against the
|
||||
sun (`sunDirection`: x = 0.9 sin(2 pi \* `sunAzimuth`), so the sun is toward +x for azimuth 0 to 0.5, strongest at 0.25, toward -x for 0.5 to 1; with no `sunAzimuth` the face lighting falls back to 0.3 and the in-scatter is off); the face under it has a light-to-haze gradient, erosion
|
||||
streaks (a folded noise at gully width, darker with steepness) and a lit rim on the crest. Contrast scales with
|
||||
`transmittance`; the base colour, haze and blur are the aerial perspective below, not redone.
|
||||
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or
|
||||
null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's
|
||||
value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain
|
||||
2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between
|
||||
layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a
|
||||
CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame.
|
||||
The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
|
||||
- Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played.
|
||||
`tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements.
|
||||
- Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over
|
||||
|
|
@ -32,9 +23,8 @@ means "keep the CSS ground". Everything below this section describes the opt-in
|
|||
in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is
|
||||
`T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1,
|
||||
via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the
|
||||
sun's azimuth in world-x (two incommensurate cosines, so not periodic). Nothing runs per frame. The in-scatter lobe is
|
||||
painted into each chunk, so it drifts with the layer rather than staying fixed under the sun; that is a known
|
||||
approximation until the sun is a real, fixed light.
|
||||
sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer
|
||||
rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light.
|
||||
- `harness/scene/bundle.cjs <out> ridge` builds a standalone page (`?tier=`, `?probe=1`).
|
||||
- Not yet measured on a phone: see the PR description.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { compile, processVanillaFile } from '@vanilla-extract/integration';
|
||||
|
||||
// vitest imports a .css.ts through the vanilla plugin, which is lenient; `vite build` compiles it with
|
||||
// vanilla-extract's integration, which refuses a function export. Compile the scene's .css.ts files the build's way.
|
||||
const FILES = [
|
||||
'src/app/features/scene/sceneShell.css.ts',
|
||||
'src/app/features/room-nav/styles.css.ts',
|
||||
'src/app/components/nav/styles.css.ts',
|
||||
];
|
||||
|
||||
describe('scene .css.ts files, compiled as the production build compiles them', () => {
|
||||
it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => {
|
||||
const filePath = `${process.cwd()}/${file}`;
|
||||
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
|
||||
await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue