Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ revokes all of them, and a pairing unused for 30 days expires.
- **Emoji suggestions** appear when you type `:` and an emoji name. If a name
is shortened with an ellipsis, hover over it or use the arrow keys to select
it and see the full name in a tooltip.
- **Mention, channel and emoji suggestions** also open when you type `@`, `#`
or `:` right after CJK punctuation such as `,` or `(`, without a space.
- **Spoilers** stay hidden in reminder previews and notifications as `[Spoiler]`.
Open the original message to reveal them.
- **Canvas** holds shared notes and context for a channel or chat. Messages and
Expand Down
7 changes: 7 additions & 0 deletions src/bundled/channels/channel-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,10 @@ it("uses the caret, rejects invalid offsets, and bounds the query", () => {
const text = `#${"a".repeat(160)}`;
expect(channelQuery(text, text.length)).toBeNull();
});
it("matches after fullwidth and CJK punctuation, also at the scan bound", () => {
for (const text of ["看看,#ge", "好。#ge", "(#ge", "「#ge"]) {
expect(channelQuery(text, text.length)?.start).toBe(text.lastIndexOf("#"));
}
const bound = `,#${"a".repeat(159)}`;
expect(channelQuery(bound, bound.length)?.start).toBe(1);
});
6 changes: 4 additions & 2 deletions src/bundled/channels/channel-query.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { TRIGGER_BOUNDARY } from "../mentions/mention-query";

/** Syntax only: no channel reads or work for ordinary typing. A query may
* contain spaces; completion admits it only while a channel name continues it. */
export function channelQuery(text: string, caret: number) {
if (!Number.isInteger(caret) || caret < 0 || caret > text.length) return null;
const before = text.slice(Math.max(0, caret - 160), caret);
const match = before.match(/(?:^|[\s([{])#([^\n\r\t#[\](){}`<>\\]*)$/u);
const match = before.match(/#([^\n\r\t#[\](){}`<>\\]*)$/u);
if (!match) return null;
const query = match[1] ?? "";
const start = caret - query.length - 1;
if (start && !/[\s([{]/u.test(text[start - 1] ?? "")) return null;
if (start && !TRIGGER_BOUNDARY.test(text[start - 1] ?? "")) return null;
return { start, end: caret, query };
}
6 changes: 6 additions & 0 deletions src/bundled/emoji/emoji-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ it("opens after two shortcode characters, at the real caret and word boundaries"
}
});

it("opens after fullwidth and CJK punctuation", () => {
for (const text of ["好的,:sm", "(:sm", "「:sm"]) {
expect(emojiQuery(text, text.length)?.start).toBe(text.lastIndexOf(":"));
}
});

it("opens right after a converted emoji or a completed shortcode, without a space", () => {
// `:smile:` converts to its glyph on the closing colon; the next shortcode starts at once.
expect(emojiQuery("😄:wa", 5)).toEqual({ start: 2, end: 5, query: "wa" });
Expand Down
6 changes: 4 additions & 2 deletions src/bundled/emoji/emoji-query.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TRIGGER_BOUNDARY } from "../mentions/mention-query";

const SHORTCODE = "[a-zA-Z0-9_+-]{2,64}";
const QUERY = new RegExp(`:(${SHORTCODE})$`, "u");
const CLOSED = new RegExp(`:${SHORTCODE}:$`, "u");
Expand All @@ -7,7 +9,7 @@ const EMOJI =
/(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u20E3|\u{E007F})[\uFE0E\uFE0F]?$/u;

/**
* Start/whitespace/opening-bracket boundaries exclude URLs, times and closed codes.
* The start of text and `TRIGGER_BOUNDARY` exclude URLs, times and closed codes.
* A converted emoji or a completed shortcode at its own boundary also counts, so
* `😄:wave` and `:bufo::wave` chain without a space while `std::vector` stays text.
*/
Expand All @@ -23,7 +25,7 @@ export function emojiQuery(text: string, caret: number) {
}

function atBoundary(prefix: string): boolean {
if (prefix === "" || /[\s([{]$/u.test(prefix) || EMOJI.test(prefix))
if (!prefix || TRIGGER_BOUNDARY.test(prefix.slice(-1)) || EMOJI.test(prefix))
return true;
const closed = prefix.match(CLOSED);
return closed !== null && atBoundary(prefix.slice(0, -closed[0].length));
Expand Down
4 changes: 3 additions & 1 deletion src/bundled/mentions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ namesakes get qualified labels.
## 2. Query syntax

Inline completion opens on `@` at the start of the text or after whitespace,
`(`, `[`, or `{`. The query is the text from after `@` up to the caret. It MUST
`(`, `[`, `{`, or one of these punctuation marks, which CJK prose does not
follow with a space: `,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜`.
The query is the text from after `@` up to the caret. It MUST
NOT contain `@`, a line break, or a tab. It MAY contain spaces. A client MAY look
back only 160 characters from the caret. A caret outside the text gives no query.

Expand Down
8 changes: 8 additions & 0 deletions src/bundled/mentions/mention-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ it("opens at word/open-bracket boundaries without treating email/URLs as mention
expect(mentionQuery(text, text.length)).toBeNull();
}
});
it("opens after fullwidth and CJK punctuation, which CJK prose does not follow with a space", () => {
for (const text of ["好的。@Ho", "(@Ho", "「@Ho", "“@Ho"]) {
expect(mentionQuery(text, text.length)?.start).toBe(text.lastIndexOf("@"));
}
// At the scan bound, the character before `@` still comes from the full text.
const bound = `,@${"x".repeat(159)}`;
expect(mentionQuery(bound, bound.length)?.start).toBe(1);
});
it("uses UTF-16 ranges at the actual caret and respects the scan bound", () => {
expect(mentionQuery("🧠 @Ho tail", 7)).toEqual({
start: 3,
Expand Down
12 changes: 9 additions & 3 deletions src/bundled/mentions/mention-query.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { profileKey } from "../../features/profiles/target";
import { foldName, normalizeName } from "../../features/search/person-match";

/** What may precede `@`, `#` or `:` for a completion to open: whitespace, an
* opening bracket, or fullwidth/CJK punctuation, which CJK prose does not
* follow with a space. */
export const TRIGGER_BOUNDARY =
/[\s([{,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]/u;

/** Syntax only. Multi-word queries are admitted separately against current names. */
export function mentionQuery(text: string, caret: number) {
if (!Number.isInteger(caret) || caret < 0 || caret > text.length) return null;
const before = text.slice(Math.max(0, caret - 160), caret);
const match = before.match(/(?:^|[\s([{])@([^@\n\r\t]*)$/u);
const match = before.match(/@([^@\n\r\t]*)$/u);
if (!match) return null;
const query = match[1] ?? "";
const start = caret - query.length - 1;
Expand All @@ -14,8 +20,8 @@ export function mentionQuery(text: string, caret: number) {
/\]\((nostr:npub1[023456789acdefghjklmnpqrstuvwxyz]+)\)$/u.exec(query);
if (text[start - 1] === "[" && completed && profileKey(completed[1] ?? ""))
return null;
// A bounded slice beginning in the middle of a word is not a boundary.
if (start && !/[\s([{]/u.test(text[start - 1] ?? "")) return null;
// Check the full text: a bounded slice can begin in the middle of a word.
if (start && !TRIGGER_BOUNDARY.test(text[start - 1] ?? "")) return null;
return { start, end: caret, query };
}

Expand Down
10 changes: 10 additions & 0 deletions src/bundled/mentions/mention-rules.fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,16 @@
"query": "Ho"
}
},
{
"name": "after_fullwidth_punctuation",
"text": "你好,@Ho",
"caret": 6,
"expected": {
"start": 3,
"end": 6,
"query": "Ho"
}
},
{
"name": "after_newline",
"text": "@x\n@y",
Expand Down
17 changes: 17 additions & 0 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,23 @@ it("reopens a query at the same offset after Escape with no carried rows", () =>
expect(h.messages.send).not.toHaveBeenCalled();
});

it("opens an @ query after CJK punctuation and sends the pick as a recipient", async () => {
const h = mountTypeahead();
const input = h.input();
input.focus();
h.fill("你好,@honey");
expect(h.rows()).toEqual(["Honey honey"]);
expect(fireEvent.keyDown(input, { key: " " })).toBe(false);
expect(input).toHaveValue("你好,@Honey ");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(h.messages.send).toHaveBeenCalledOnce());
expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([
"channel",
"你好,@Honey ",
[first.pubkey],
]);
});

it("still closes the popup when the query closes, on Escape, and on a Space that selects", () => {
const h = mountTypeahead();
const input = h.input();
Expand Down
Loading