composebox_typeahead: Allow easily linking to topics in same channel.

This commit makes it easier to link to topics in the same channel.
Typing `#>` in the compose box replaces it with `#**channel_name>`
and opens the topic_list typeahead.

Channel names producing broken link syntax
are handled by generating a fallback md link
syntax when the topic is chosen.

Fixes #31420
This commit is contained in:
Kislay Verma
2025-02-23 14:57:25 +05:30
committed by Tim Abbott
parent 1500ba4211
commit 766467c5e3
2 changed files with 101 additions and 18 deletions

View File

@@ -31,6 +31,7 @@ import * as stream_data from "./stream_data.ts";
import type {StreamPillData} from "./stream_pill.ts";
import * as stream_topic_history from "./stream_topic_history.ts";
import * as stream_topic_history_util from "./stream_topic_history_util.ts";
import type * as sub_store from "./sub_store.ts";
import * as timerender from "./timerender.ts";
import * as topic_link_util from "./topic_link_util.ts";
import * as typeahead_helper from "./typeahead_helper.ts";
@@ -88,6 +89,7 @@ export type TopicSuggestion = {
// is_channel_link will be used when we want to only render the stream as an
// option in the topic typeahead while having #**stream_name> as the token.
is_channel_link: boolean;
is_shortcut_syntax_used: boolean;
stream_data: StreamPillData;
};
@@ -931,27 +933,45 @@ export function get_candidates(
// Matches '#**stream name>some text' at the end of a split.
const stream_topic_regex = /#\*\*([^*>]+)>([^*\n]*)$/;
const should_begin_typeahead = stream_topic_regex.test(split[0]);
// Matches '#>some text', which is a shortcut to
// link to topics in the channel currently composing to.
// `>` is enclosed in a capture group to use the below
// code path for both syntaxes.
const shortcut_regex = /#(>)([^*\n]*)$/;
const stream_topic_tokens = stream_topic_regex.exec(split[0]);
const topic_shortcut_tokens = shortcut_regex.exec(split[0]);
const tokens = stream_topic_tokens ?? topic_shortcut_tokens;
const should_begin_typeahead = tokens !== null;
if (should_begin_typeahead) {
completing = "topic_list";
const tokens = stream_topic_regex.exec(split[0]);
assert(tokens !== null);
if (tokens[1]) {
const stream_name = tokens[1];
token = tokens[2] ?? "";
// Don't autocomplete if there is a space following '>'
if (token.startsWith(" ")) {
return [];
let sub: sub_store.StreamSubscription | undefined;
let is_shortcut_syntax_used = false;
if (tokens[1] === ">") {
// The shortcut syntax is used.
const stream_id = compose_state.stream_id();
if (stream_id !== undefined) {
sub = stream_data.get_sub_by_id(stream_id);
}
is_shortcut_syntax_used = true;
} else {
const stream_name = tokens[1];
assert(stream_name !== undefined);
sub = stream_data.get_sub_by_name(stream_name);
}
const stream_id = stream_data.get_stream_id(stream_name);
const sub = stream_data.get_sub_by_name(stream_name);
assert(sub !== undefined);
token = tokens[2] ?? "";
// Don't autocomplete if there is a space following '>'
if (token.startsWith(" ")) {
return [];
}
// If we aren't composing to a channel, `sub` would be undefined.
if (sub !== undefined) {
// We always show topic suggestions after the user types a stream, and let them
// pick between just showing the stream (the first option, when nothing follows ">")
// or adding a topic.
const topic_list = topics_seen_for(stream_id);
const topic_list = topics_seen_for(sub.stream_id);
if (should_show_custom_query(token, topic_list)) {
topic_list.push(token);
@@ -962,6 +982,7 @@ export function get_candidates(
topic,
type: "topic_list",
is_channel_link: false,
is_shortcut_syntax_used,
stream_data: {
...sub,
type: "stream",
@@ -979,9 +1000,10 @@ export function get_candidates(
// Add link to channel if and only if nothing is typed after '>'
if (token.length === 0) {
topic_suggestion_candidates.unshift({
topic: stream_name,
topic: sub.name,
type: "topic_list",
is_channel_link: true,
is_shortcut_syntax_used,
stream_data: {
...sub,
type: "stream",
@@ -1226,8 +1248,8 @@ export function content_typeahead_selected(
// will cause encoding issues.
// "beginning" contains all the text before the cursor, so we use lastIndexOf to
// avoid any other stream+topic mentions in the message.
const syntax_start_index = beginning.lastIndexOf("#**");
const syntax_text = item.is_shortcut_syntax_used ? "#>" : "#**";
const syntax_start_index = beginning.lastIndexOf(syntax_text);
let replacement_text;
if (item.is_channel_link) {
// The user opted to select only the stream and not specify a topic.

View File

@@ -547,12 +547,21 @@ const mobile_team_stream = stream_item({
can_administer_channel_group: support.id,
can_add_subscribers_group: support.id,
});
const broken_link_stream = stream_item({
name: "A* Algorithm",
description: "A `*` in the stream name produces a broken #**stream>topic** link",
stream_id: 6,
subscribed: true,
can_administer_channel_group: support.id,
can_add_subscribers_group: support.id,
});
stream_data.add_sub(sweden_stream);
stream_data.add_sub(denmark_stream);
stream_data.add_sub(netherland_stream);
stream_data.add_sub(mobile_stream);
stream_data.add_sub(mobile_team_stream);
stream_data.add_sub(broken_link_stream);
const make_emoji = (emoji_dict) => ({
emoji_name: emoji_dict.name,
@@ -869,6 +878,9 @@ test("content_typeahead_selected", ({override}) => {
{
topic: "testing",
type: "topic_list",
stream_data: {
name: "Sweden",
},
},
query,
input_element,
@@ -882,6 +894,28 @@ test("content_typeahead_selected", ({override}) => {
{
topic: "testing",
type: "topic_list",
stream_data: {
name: "Sweden",
},
},
query,
input_element,
);
expected_value = "Hello #**Sweden>testing** ";
assert.equal(actual_value, expected_value);
// shortcut syntax for topic_list
compose_state.set_stream_id(sweden_stream.stream_id);
query = "Hello #>";
ct.get_or_set_token_for_testing("");
actual_value = ct.content_typeahead_selected(
{
topic: "testing",
type: "topic_list",
is_shortcut_syntax_used: true,
stream_data: {
name: "Sweden",
},
},
query,
input_element,
@@ -896,6 +930,9 @@ test("content_typeahead_selected", ({override}) => {
topic: "Sweden",
type: "topic_list",
is_channel_link: false,
stream_data: {
name: "Sweden",
},
},
query,
input_element,
@@ -906,15 +943,38 @@ test("content_typeahead_selected", ({override}) => {
ct.get_or_set_token_for_testing("");
actual_value = ct.content_typeahead_selected(
{
topic: "Sweden",
topic: "",
type: "topic_list",
is_channel_link: true,
stream_data: {
name: "Sweden",
},
},
query,
input_element,
);
expected_value = "Hello #**Sweden** ";
assert.equal(actual_value, expected_value);
compose_state.set_stream_id(broken_link_stream.stream_id);
query = "Hello #>";
ct.get_or_set_token_for_testing("");
actual_value = ct.content_typeahead_selected(
{
topic: "",
type: "topic_list",
is_channel_link: true,
is_shortcut_syntax_used: true,
stream_data: {
name: "A* Algorithm",
},
},
query,
input_element,
);
expected_value = "Hello [#A* Algorithm](#narrow/channel/6-A*-Algorithm) ";
assert.equal(actual_value, expected_value);
// syntax
ct.get_or_set_completing_for_tests("syntax");
@@ -1941,6 +2001,7 @@ test("begins_typeahead", ({override, override_rewire}) => {
function typed_topics(topics) {
const matches_list = topics.map((topic) => ({
is_channel_link: false,
is_shortcut_syntax_used: false,
stream_data: {
...stream_data.get_sub_by_name("Sweden"),
rendered_description: "",