# Comments

Import these from `@grafloria/engine`.

## On their own pages

- [`CommentStore`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-engine-comments-commentstore)

## Functions

### `mentionIds`

Just the ids — what gets stored on the message register.

```ts
function mentionIds(body: string): string[]
```

### `mentionKey`

The idempotency key. Derived only from op-borne data — never from local state.

```ts
function mentionKey(threadId: string, messageId: string): string
```

### `messageKey`

The sort key of a message, as an opaque comparable string. Used by read-state.

```ts
function messageKey(m: CommentMessage): string
```

### `messageOrder`

THE TOTAL ORDER ON MESSAGES.

Every peer must list a thread's messages in the SAME order or two people reading the
same conversation read different conversations. `(createdAt, author, id)` is total
(ids are unique) and it is computed from data that travels IN the ops, so it is
identical on every peer and stable across replay.

It is wall time, and that is a deliberate, bounded concession. Wall time may not
decide MERGE — a skewed clock deciding which edit wins is silent data loss, which is
why the substrate uses Lamport clocks for that and why this function is not used for
anything but display order. Wall time deciding the ORDER OF A CHAT LIST is what every
messaging product on earth does, and its worst case is cosmetic: two messages sent
within the clock skew of each other may read in the wrong order. Nobody loses a
comment. (The alternative — a per-message Lamport stamp — is unavailable without
reaching into OpCapture's clock, and buying strict causal order for a chat list at
the price of a second way to mint stamps is a bad trade.)

```ts
function messageOrder(a: CommentMessage, b: CommentMessage): number
```

### `parseMentions`

Extract the mentions from a body. Pure, and total: never throws, never re-orders.

Deduplicated by id — mentioning Ada three times in one message is one notification, not
three, and that is a property of the DATA, not of the notifier's retry policy.

```ts
function parseMentions(body: string): MentionRef[]
```

## Classes

### `ReadState`

What THIS viewer has seen. Local. Never synced.

```ts
class ReadState
```

**Methods**

- `constructor(readonly viewer: string)`
- `markRead(threadId: string, messages: readonly CommentMessage[]): void` — Mark a thread read up to its newest message.

Takes the messages rather than a key so the caller cannot accidentally set a
watermark ahead of the messages it has actually seen — which would silently swallow
a message that is still in flight.
- `markUnread(threadId: string): void` — Explicitly mark unread again (a "mark as unread" affordance).
- `unreadCount(threadId: string, messages: readonly CommentMessage[]): number` — How many messages in this thread the viewer has not seen.

A message YOU wrote is never unread — you were there. A tombstoned message is never
unread either: a badge that says "1 unread" and opens onto "message deleted" is a
badge that has wasted someone's attention, which is the only currency a notification
has.
- `isRead(threadId: string, messages: readonly CommentMessage[]): boolean`
- `toJSON(): SerializedReadState`
- `static fromJSON(data: SerializedReadState): ReadState` (static)

## Interfaces

### `CommentMessage`

One message. ONE register — the rule that makes concurrent replies survive.

An edit rewrites the whole register (an edit IS a whole-body replacement, so nothing
is gained by splitting it). A delete rewrites it as a TOMBSTONE — `deleted: true`,
body cleared, author and createdAt kept — rather than removing the key, because
removing it would let a concurrent edit resurrect the message with no body, and
because the ordering of the surviving messages must not shift under a peer that has
not yet seen the delete.

```ts
interface CommentMessage
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  |  |
| `author` | `string` |  |  |
| `body` | `string` |  |  |
| `createdAt` | `number` |  |  |
| `mentions?` | `string[]` |  | User ids extracted from the body at authoring time. Convergent, queryable. |
| `editedAt?` | `number` |  |  |
| `deleted?` | `boolean` |  |  |

### `CommentStoreOptions`

```ts
interface CommentStoreOptions
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `viewer` | `string` |  | The PERSON at this peer. Not the actor/session id. |
| `idFactory?` | `() => string` |  | Deterministic ids for tests. Default: unique by construction (see `mintId`). |
| `now?` | `() => number` |  | Deterministic time for tests. Default: Date.now. |
| `notifier?` | `MentionNotifier` |  | The |
| `readState?` | `ReadState` |  | This viewer's unread watermarks — hand back what you persisted. |
| `labelOf?` | `(kind: 'node' \| 'link', id: string) => string \| undefined` |  | How to name an entity. Default: `metadata.label`, then `type`, then the id. |

### `CommentThreadHead`

Write-once thread identity. One register, so a thread appears whole or not at all.

```ts
interface CommentThreadHead
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  |  |
| `author` | `string` |  | The PERSON who started the thread (a user id, not a session/actor id). |
| `createdAt` | `number` |  | Wall-clock milliseconds, captured at authoring time and carried IN the op. |

### `CommentThreadStatus`

Resolve/reopen. ONE register, so the flag and its attribution can never split.

```ts
interface CommentThreadStatus
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `resolved` | `boolean` |  |  |
| `by` | `string` |  |  |
| `at` | `number` |  |  |

### `CommentThreadView`

A thread, assembled for reading: sorted messages, derived anchor, viewer's unread count.

```ts
interface CommentThreadView
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  |  |
| `author` | `string` |  |  |
| `createdAt` | `number` |  |  |
| `anchor` | `CommentAnchor` |  |  |
| `resolved` | `boolean` |  |  |
| `resolvedBy?` | `string` |  |  |
| `resolvedAt?` | `number` |  |  |
| `messages` | `CommentMessage[]` |  | Total order, identical on every peer. Tombstones included (flagged `deleted`). |
| `unread` | `number` |  | Messages this viewer has not seen, authored by someone else. LOCAL, never synced. |
| `resolvedAnchor` | `ResolvedAnchor` |  | Derived from the live diagram — see ResolvedAnchor. |

### `MentionEvent`

What a notifier is handed. Everything it needs, nothing it would have to guess.

`anchor` is in here on purpose: "Ada was mentioned" is useless; "Ada was mentioned in a
thread on the Payment gateway node" is a notification a human can act on without opening
the file.

```ts
interface MentionEvent
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `key` | `string` |  | STABLE ACROSS PEERS AND ACROSS REPLAYS. Dedupe on this or send N copies. See header. |
| `diagramId` | `string` |  |  |
| `threadId` | `string` |  |  |
| `messageId` | `string` |  |  |
| `mentioned` | `string[]` |  | User ids. The host's directory turns these into people. |
| `author` | `string` |  | The user id of whoever wrote the message. |
| `body` | `string` |  |  |
| `anchor` | `CommentAnchor` |  |  |
| `createdAt` | `number` |  |  |
| `local` | `boolean` |  | True when this peer is the one that authored the message. |

### `MentionNotifier`

The seam. One method. The engine calls it; the host implements it.

```ts
interface MentionNotifier
```

**Members**

- `notify(event: MentionEvent): void`

### `MentionRef`

One

```ts
interface MentionRef
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | The user id to notify. From `@[Name](id)` this is `id`; from `@handle` it is `handle`. |
| `display` | `string` |  | What was written on screen, for rendering the chip. |
| `raw` | `string` |  | The exact source text, so a renderer can replace it in place. |
| `index` | `number` |  | Character offset of `raw` in the body. |

### `ResolvedAnchor`

WHERE A THREAD'S PIN GOES, AND WHETHER ITS SUBJECT STILL EXISTS.

`attached` is DERIVED — computed from the live diagram on every read, never stored
and never synced. That is the single most important decision in this file, and it is
argued at length in comment-store.ts (`resolveAnchor`).

```ts
interface ResolvedAnchor
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `point` | `WorldPoint` |  | World coordinates for the pin. |
| `attached` | `boolean` |  | False ⇒ ORPHANED: the node/link this thread is about is not in the diagram. |
| `targetLabel` | `string` |  | The live label if attached; the snapshot taken at anchor time if not. |
| `targetKind` | `'node' \| 'link' \| 'region'` |  |  |
| `targetId?` | `string` |  |  |

### `SerializedReadState`

The serializable shape. Give this to the host to persist; hand it back on load.

```ts
interface SerializedReadState
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `viewer` | `string` |  |  |
| `watermarks` | `Record<string, string>` |  | threadId → the messageKey of the newest message this viewer has seen. |

### `StoredThread`

The raw register tree as it sits on the model and in the serialized document.

```ts
interface StoredThread
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `head?` | `CommentThreadHead` |  |  |
| `anchor?` | `CommentAnchor` |  |  |
| `status?` | `CommentThreadStatus` |  |  |
| `messages?` | `Record<string, CommentMessage>` |  |  |

### `WorldPoint`

A point in WORLD space. Never screen space — see CommentAnchor.

```ts
interface WorldPoint
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `x` | `number` |  |  |
| `y` | `number` |  |  |

## Types

### `AnchorSpec`

What a caller asks for. The store fills in the obituary (fallback point + label).

```ts
type AnchorSpec =
  | { kind: 'node'; id: string }
  | { kind: 'link'; id: string }
  | { kind: 'region'; x: number; y: number; width?: number; height?: number };
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `'node'` |  |  |

### `CommentAnchor`

WHAT A COMMENT IS ABOUT.

ANCHOR BY IDENTITY, NEVER BY COORDINATES — for anything that has an identity. A
comment pinned to (420, 180) is a comment about whatever happens to be at (420, 180)
*right now*, which after one auto-layout is a different node and a different meaning. The comment would still be there, still timestamped, still confidently wrong. So a
node/link anchor stores the ENTITY ID and derives its position from the live entity
every frame; move the node across the canvas and the pin comes with it, and not one
op is emitted to make that happen (position is derived, and derived state is never
synced — the same rule capture.ts applies to link routes).

A FREE-REGION anchor genuinely is coordinates, and they are WORLD coordinates. Screen
coordinates would make the pin's meaning depend on the reader's scroll position and
zoom — a note about the top-right corner of a subsystem would drift onto empty canvas
the moment anyone panned. World coordinates are invariant under pan and zoom by
construction, and the renderer draws them inside the viewBox, so the pin sticks to
the diagram rather than to the glass.

`fallback` and `targetLabel` are the ANCHOR'S OBITUARY: where the target was and what
it was called, captured at the moment the thread was created. They exist for exactly
one purpose — so that a thread whose target has been DELETED can still be shown, in
the right place, saying what it was about. See ResolvedAnchor.

```ts
type CommentAnchor =
  | {
      kind: 'node';

      id: string;

      fallback: WorldPoint;

      targetLabel?: string;
    }
  | {
      kind: 'link';
      id: string;
      fallback: WorldPoint;
      targetLabel?: string;
    }
  | {

      kind: 'region';
      x: number;
      y: number;
      width?: number;
      height?: number;
    };
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `'node'` |  |  |

### `CommentRegisterTree`

```ts
type CommentRegisterTree = Record<string, StoredThread>;
```
