multica/apps/mobile/lib/mention-serialize.ts

176 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Mention serialization for the mobile comment composer.
*
* Mobile RN `<TextInput>` cannot host inline custom views (no equivalent of
* web's TipTap NodeView), so we use a zero-width sentinel character —
* U+2063 INVISIBLE SEPARATOR — to mark every `@` that came from the
* suggestion bar (vs. ones the user typed manually).
*
* Display while editing: plain text `Hi @bohan please` (sentinel invisible).
* On send: scan for `@<word>` runs, zip with the ordered `markers`
* list to produce `[@<name>](mention://<type>/<id>)` markdown that the
* backend's `util.ParseMentions` regex (server/internal/util/mention.go:16)
* already accepts. **Issues drop the `@` in the label** — they render as
* `[MUL-123](mention://issue/<uuid>)` to match web (mention-extension.ts).
*
* Sentinel mismatch (e.g. user copy-paste broke a marker) → serializer
* falls back to plain text with all sentinels stripped: never crash, never
* lose user input, never claim a mention we can't prove.
*/
const SENTINEL = "";
export type MentionType = "member" | "agent" | "squad" | "all" | "issue";
export interface MentionMarker {
type: MentionType;
/** UUID for member/agent/squad/issue, the literal "all" for @all. */
id: string;
/** Display name without the leading `@`. For issues this is the
* identifier (e.g. "MUL-123"). May contain non-ASCII chars. */
name: string;
}
/**
* Detects whether the cursor is currently inside an `@<token>` run that
* should trigger the mention suggestion bar.
*
* Returns the start offset of the `@` and the query (text between `@` and
* the cursor), or null when not in a mention token.
*
* Word boundary uses `/\s/` so non-ASCII names (中文 / 日本語) work — the
* token ends at whitespace, not at ASCII word boundary.
*
* Skips runs that begin with the sentinel — those are completed mentions
* inserted by the bar, not in-progress queries.
*/
export function tokenAtCursor(
text: string,
cursor: number,
): { start: number; query: string } | null {
if (cursor < 1 || cursor > text.length) return null;
// Walk back from cursor until we hit @ / whitespace / start of text.
let i = cursor - 1;
while (i >= 0) {
const ch = text[i];
if (ch === "@") break;
if (ch === undefined || /\s/.test(ch)) return null;
i--;
}
if (i < 0 || text[i] !== "@") return null;
// Skip if the @ is preceded by the sentinel (= a completed mention chip).
if (i > 0 && text[i - 1] === SENTINEL) return null;
// The character before @ must be whitespace or start-of-string. This
// prevents random in-word @ (e.g. "user@example.com") from triggering.
if (i > 0) {
const prev = text[i - 1];
if (prev !== undefined && !/\s/.test(prev)) return null;
}
const query = text.slice(i + 1, cursor);
// If the query already contains whitespace, the user has moved past the
// token — abort.
if (/\s/.test(query)) return null;
return { start: i, query };
}
/**
* Replaces the in-progress `@<query>` run with `@<name> ` and returns
* the new text + cursor + the marker the caller should push onto its
* ordered list of markers.
*/
export function insertMention(
text: string,
query: { start: number; queryLength: number },
mention: MentionMarker,
): {
newText: string;
newSelection: { start: number; end: number };
marker: MentionMarker;
} {
// Replace text[start .. start + 1 + queryLength] (the `@` plus the query)
// with the sentinel + @<name> + trailing space.
const before = text.slice(0, query.start);
const after = text.slice(query.start + 1 + query.queryLength);
const insert = `${SENTINEL}@${mention.name} `;
const newText = before + insert + after;
const cursor = before.length + insert.length;
return {
newText,
newSelection: { start: cursor, end: cursor },
marker: mention,
};
}
/**
* Walks the text left-to-right, finding each `@<word>` run and
* pairing it (in order) with the ordered `markers` list. Each pair is
* replaced with the canonical `[@<name>](mention://<type>/<id>)` markdown
* link that backend's `ParseMentions` recognises.
*
* Robustness:
* - Sentinel count != marker count → fallback. Strip all sentinels and
* return plain text. The user never loses content; mentions just
* don't get linked.
* - Stray sentinels not followed by `@` → stripped.
* - Sentinel-marked runs whose word doesn't match the marker name →
* fallback (defensive; users who hand-edit the inserted token).
*/
export function serializeMentions(
text: string,
markers: MentionMarker[],
): string {
// Fast path: no sentinels.
if (!text.includes(SENTINEL)) return text;
const out: string[] = [];
let cursor = 0;
let markerIndex = 0;
let abort = false;
while (cursor < text.length) {
const sentinelAt = text.indexOf(SENTINEL, cursor);
if (sentinelAt === -1) {
out.push(text.slice(cursor));
break;
}
out.push(text.slice(cursor, sentinelAt));
// Verify the sentinel is followed by `@<word>`.
if (text[sentinelAt + 1] !== "@") {
// Stray sentinel — drop it, keep walking.
cursor = sentinelAt + 1;
continue;
}
// Read the word after `@` until whitespace or end-of-text.
let wordEnd = sentinelAt + 2;
while (wordEnd < text.length && !/\s/.test(text[wordEnd]!)) wordEnd++;
const word = text.slice(sentinelAt + 2, wordEnd);
const marker = markers[markerIndex];
if (!marker || marker.name !== word) {
// Marker exhausted or word doesn't match — abort and fallback.
abort = true;
break;
}
// Issues render without the leading `@` in the link label (mirrors
// web's mention-extension.ts:67-74). Members / agents / @all keep it.
const label =
marker.type === "issue" ? marker.name : `@${marker.name}`;
out.push(`[${label}](mention://${marker.type}/${marker.id})`);
markerIndex++;
cursor = wordEnd;
}
if (abort || markerIndex !== markers.length) {
// Mismatch — strip all sentinels and return plain text.
return text.replace(new RegExp(SENTINEL, "g"), "");
}
return out.join("");
}