Skip to content

Commit 6b2fb06

Browse files
committed
channel: Finish channel link autocomplete for compose box
Figma design: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7952-30060&t=YfdW2W1p4ROsq9db-0 Fixes-partly: #124
1 parent a03d5b3 commit 6b2fb06

File tree

4 files changed

+272
-5
lines changed

4 files changed

+272
-5
lines changed

lib/model/compose.dart

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,69 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
185185
String userGroupMention(String userGroupName, {bool silent = false}) =>
186186
'@${silent ? '_' : ''}*$userGroupName*';
187187

188+
// Corresponds to `topic_link_util.escape_invalid_stream_topic_characters`
189+
// in Zulip web:
190+
// https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L15-L34
191+
const _channelTopicFaultyCharsReplacements = {
192+
'`': '`',
193+
'>': '>',
194+
'*': '*',
195+
'&': '&',
196+
'[': '[',
197+
']': ']',
198+
r'$$': '$$',
199+
};
200+
201+
final _channelTopicFaultyCharsRegex = RegExp(r'[`>*&[\]]|(?:\$\$)');
202+
203+
/// Markdown link for channel, topic, or message when the channel or topic name
204+
/// includes characters that will break normal markdown rendering.
205+
///
206+
/// Refer to [_channelTopicFaultyCharsReplacements] for a complete list of
207+
/// these characters.
208+
// Corresponds to `topic_link_util.get_fallback_markdown_link` in Zulip web;
209+
// https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L96-L108
210+
String fallbackMarkdownLink({
211+
required PerAccountStore store,
212+
required ZulipStream channel,
213+
TopicName? topic,
214+
int? nearMessageId,
215+
}) {
216+
assert(nearMessageId == null || topic != null);
217+
218+
String replaceFaultyChars(String str) {
219+
return str.replaceAllMapped(_channelTopicFaultyCharsRegex,
220+
(match) => _channelTopicFaultyCharsReplacements[match[0]]!);
221+
}
222+
223+
final StringBuffer text = StringBuffer(replaceFaultyChars(channel.name));
224+
if (topic != null) {
225+
text.write(' > ${replaceFaultyChars(topic.displayName ?? store.realmEmptyTopicDisplayName)}');
226+
}
227+
if (nearMessageId != null) {
228+
text.write(' @ 💬');
229+
}
230+
231+
final narrow = topic == null
232+
? ChannelNarrow(channel.streamId) : TopicNarrow(channel.streamId, topic);
233+
final linkFragment = narrowLinkFragment(store, narrow, nearMessageId: nearMessageId);
234+
235+
return inlineLink(text.toString(), '#$linkFragment');
236+
}
237+
238+
/// A #channel link syntax of a channel, like #**announce**.
239+
///
240+
/// [fallbackMarkdownLink] will be used if the channel name includes some faulty
241+
/// characters that will break normal #**channel** rendering.
242+
String channelLinkSyntax(ZulipStream channel, {
243+
required PerAccountStore store,
244+
}) {
245+
if (_channelTopicFaultyCharsRegex.hasMatch(channel.name)) {
246+
return fallbackMarkdownLink(store: store, channel: channel);
247+
}
248+
return '#**${channel.name}**';
249+
}
250+
188251
/// https://spec.commonmark.org/0.30/#inline-link
189252
///
190253
/// The "link text" is made by enclosing [visibleText] in square brackets.

lib/widgets/autocomplete.dart

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import 'package:flutter/material.dart';
22

33
import '../generated/l10n/zulip_localizations.dart';
4+
import '../model/content.dart';
45
import '../model/emoji.dart';
56
import '../model/store.dart';
7+
import 'content.dart';
68
import 'emoji.dart';
79
import 'icons.dart';
810
import 'store.dart';
@@ -45,8 +47,7 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
4547
}
4648

4749
void _handleControllerChange() {
48-
var newQuery = widget.autocompleteIntent()?.query;
49-
if (newQuery is ChannelLinkAutocompleteQuery) newQuery = null; // TODO(#124)
50+
final newQuery = widget.autocompleteIntent()?.query;
5051
// First, tear down the old view-model if necessary.
5152
if (_viewModel != null
5253
&& (newQuery == null
@@ -227,8 +228,17 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
227228
// TODO(#1805) language-appropriate space character; check active keyboard?
228229
// (maybe handle centrally in `controller`)
229230
replacementString = '${userGroupMention(userGroup.name, silent: query.silent)} ';
230-
case ChannelLinkAutocompleteResult():
231-
throw UnimplementedError(); // TODO(#124)
231+
case ChannelLinkAutocompleteResult(:final channelId):
232+
if (query is! ChannelLinkAutocompleteQuery) {
233+
return; // Shrug; similar to `intent == null` case above.
234+
}
235+
final channel = store.streams[channelId];
236+
if (channel == null) {
237+
// Don't crash on theoretical race between async results-filtering
238+
// and losing data for the channel.
239+
return;
240+
}
241+
replacementString = '${channelLinkSyntax(channel, store: store)} ';
232242
}
233243

234244
controller.value = intent.textEditingValue.replaced(
@@ -246,7 +256,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
246256
final child = switch (option) {
247257
MentionAutocompleteResult() => MentionAutocompleteItem(
248258
option: option, narrow: narrow),
249-
ChannelLinkAutocompleteResult() => throw UnimplementedError(), // TODO(#124)
259+
ChannelLinkAutocompleteResult() => _ChannelLinkAutocompleteItem(option: option),
250260
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
251261
};
252262
return InkWell(
@@ -361,6 +371,68 @@ class MentionAutocompleteItem extends StatelessWidget {
361371
}
362372
}
363373

374+
class _ChannelLinkAutocompleteItem extends StatelessWidget {
375+
const _ChannelLinkAutocompleteItem({required this.option});
376+
377+
final ChannelLinkAutocompleteResult option;
378+
379+
@override
380+
Widget build(BuildContext context) {
381+
final store = PerAccountStoreWidget.of(context);
382+
final zulipLocalizations = ZulipLocalizations.of(context);
383+
final designVariables = DesignVariables.of(context);
384+
385+
final channel = store.streams[option.channelId];
386+
387+
// A null [Icon.icon] makes a blank space.
388+
IconData? icon;
389+
Color? iconColor;
390+
String label;
391+
String? subLabel;
392+
if (channel != null) {
393+
icon = iconDataForStream(channel);
394+
iconColor = colorSwatchFor(context, store.subscriptions[channel.streamId])
395+
.iconOnPlainBackground;
396+
label = channel.name;
397+
subLabel = channel.renderedDescription.isNotEmpty
398+
? channel.renderedDescription : null;
399+
} else {
400+
icon = null;
401+
iconColor = null;
402+
label = zulipLocalizations.unknownChannelName;
403+
subLabel = null;
404+
}
405+
406+
final labelWidget = Text(label,
407+
overflow: TextOverflow.ellipsis,
408+
style: TextStyle(
409+
fontSize: 18, height: 20 / 18,
410+
color: designVariables.contextMenuItemLabel,
411+
).merge(weightVariableTextStyle(context, wght: 600)));
412+
413+
final subLabelWidget = subLabel == null ? null
414+
// Adapted from [MessageContent].
415+
: DefaultTextStyle(
416+
style: ContentTheme.of(context).textStylePlainParagraph.merge(
417+
TextStyle(fontSize: 14, height: 16 / 14,
418+
overflow: TextOverflow.ellipsis,
419+
color: designVariables.contextMenuItemMeta)),
420+
child: BlockContentList(
421+
nodes: parseContent(channel!.renderedDescription).nodes),
422+
);
423+
424+
return Padding(
425+
padding: EdgeInsetsGeometry.fromSTEB(12, 4, 10, 4),
426+
child: Row(spacing: 10, children: [
427+
SizedBox.square(dimension: 24,
428+
child: Icon(size: 18, color: iconColor, icon)),
429+
Expanded(child: Column(
430+
crossAxisAlignment: CrossAxisAlignment.start,
431+
children: [labelWidget, ?subLabelWidget])),
432+
]));
433+
}
434+
}
435+
364436
class _EmojiAutocompleteItem extends StatelessWidget {
365437
const _EmojiAutocompleteItem({required this.option});
366438

test/model/compose_test.dart

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:checks/checks.dart';
22
import 'package:test/scaffolding.dart';
33
import 'package:zulip/api/model/events.dart';
4+
import 'package:zulip/api/model/model.dart';
45
import 'package:zulip/model/compose.dart';
56
import 'package:zulip/model/localizations.dart';
67
import 'package:zulip/model/store.dart';
@@ -334,6 +335,80 @@ hello
334335
});
335336
});
336337

338+
test('fallbackMarkdownLink', () {
339+
final store = eg.store();
340+
final channels = [
341+
eg.stream(streamId: 1, name: '`code`'),
342+
eg.stream(streamId: 2, name: 'score > 90'),
343+
eg.stream(streamId: 3, name: 'A*'),
344+
eg.stream(streamId: 4, name: 'R&D'),
345+
eg.stream(streamId: 5, name: 'UI [v2]'),
346+
eg.stream(streamId: 6, name: r'Save $$'),
347+
];
348+
store.addStreams(channels);
349+
350+
check(fallbackMarkdownLink(store: store,
351+
channel: channels[1 - 1]))
352+
.equals('[&#96;code&#96;](#narrow/channel/1-.60code.60)');
353+
check(fallbackMarkdownLink(store: store,
354+
channel: channels[2 - 1], topic: TopicName('topic')))
355+
.equals('[score &gt; 90 > topic](#narrow/channel/2-score-.3E-90/topic/topic)');
356+
check(fallbackMarkdownLink(store: store,
357+
channel: channels[3 - 1], topic: TopicName('R&D')))
358+
.equals('[A&#42; > R&amp;D](#narrow/channel/3-A*/topic/R.26D)');
359+
check(fallbackMarkdownLink(store: store,
360+
channel: channels[4 - 1], topic: TopicName('topic'), nearMessageId: 10))
361+
.equals('[R&amp;D > topic @ 💬](#narrow/channel/4-R.26D/topic/topic/near/10)');
362+
check(fallbackMarkdownLink(store: store,
363+
channel: channels[5 - 1], topic: TopicName(r'Save $$'), nearMessageId: 10))
364+
.equals('[UI &#91;v2&#93; > Save &#36;&#36; @ 💬](#narrow/channel/5-UI-.5Bv2.5D/topic/Save.20.24.24/near/10)');
365+
check(() => fallbackMarkdownLink(store: store,
366+
channel: channels[6 - 1], nearMessageId: 10))
367+
.throws<AssertionError>();
368+
});
369+
370+
group('channel link syntax', () {
371+
test('channels with normal names', () {
372+
final store = eg.store();
373+
final channels = [
374+
eg.stream(name: 'mobile'),
375+
eg.stream(name: 'dev-ops'),
376+
eg.stream(name: 'ui/ux'),
377+
eg.stream(name: 'api_v3'),
378+
eg.stream(name: 'build+test'),
379+
eg.stream(name: 'init()'),
380+
];
381+
store.addStreams(channels);
382+
383+
check(channelLinkSyntax(channels[0], store: store)).equals('#**mobile**');
384+
check(channelLinkSyntax(channels[1], store: store)).equals('#**dev-ops**');
385+
check(channelLinkSyntax(channels[2], store: store)).equals('#**ui/ux**');
386+
check(channelLinkSyntax(channels[3], store: store)).equals('#**api_v3**');
387+
check(channelLinkSyntax(channels[4], store: store)).equals('#**build+test**');
388+
check(channelLinkSyntax(channels[5], store: store)).equals('#**init()**');
389+
});
390+
391+
test('channels with names containing faulty characters', () {
392+
final store = eg.store();
393+
final channels = [
394+
eg.stream(streamId: 1, name: '`code`'),
395+
eg.stream(streamId: 2, name: 'score > 90'),
396+
eg.stream(streamId: 3, name: 'A*'),
397+
eg.stream(streamId: 4, name: 'R&D'),
398+
eg.stream(streamId: 5, name: 'UI [v2]'),
399+
eg.stream(streamId: 6, name: r'Save $$'),
400+
];
401+
store.addStreams(channels);
402+
403+
check(channelLinkSyntax(channels[1 - 1], store: store)).equals('[&#96;code&#96;](#narrow/channel/1-.60code.60)');
404+
check(channelLinkSyntax(channels[2 - 1], store: store)).equals('[score &gt; 90](#narrow/channel/2-score-.3E-90)');
405+
check(channelLinkSyntax(channels[3 - 1], store: store)).equals('[A&#42;](#narrow/channel/3-A*)');
406+
check(channelLinkSyntax(channels[4 - 1], store: store)).equals('[R&amp;D](#narrow/channel/4-R.26D)');
407+
check(channelLinkSyntax(channels[5 - 1], store: store)).equals('[UI &#91;v2&#93;](#narrow/channel/5-UI-.5Bv2.5D)');
408+
check(channelLinkSyntax(channels[6 - 1], store: store)).equals('[Save &#36;&#36;](#narrow/channel/6-Save-.24.24)');
409+
});
410+
});
411+
337412
test('inlineLink', () {
338413
check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)');
339414
check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()');

test/widgets/autocomplete_test.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import 'package:zulip/model/store.dart';
1616
import 'package:zulip/model/typing_status.dart';
1717
import 'package:zulip/widgets/autocomplete.dart';
1818
import 'package:zulip/widgets/compose_box.dart';
19+
import 'package:zulip/widgets/content.dart';
20+
import 'package:zulip/widgets/icons.dart';
1921
import 'package:zulip/widgets/message_list.dart';
2022
import 'package:zulip/widgets/user.dart';
2123

@@ -354,6 +356,61 @@ void main() {
354356
});
355357
});
356358

359+
group('#channel link', () {
360+
void checkChannelShown(ZulipStream channel, {required bool expected}) {
361+
check(find.byIcon(iconDataForStream(channel))).findsAtLeast(expected ? 1 : 0);
362+
check(find.text(channel.name)).findsExactly(expected ? 1 : 0);
363+
364+
final shouldShowDescription = expected && channel.renderedDescription.isNotEmpty;
365+
check(find.byType(BlockContentList)).findsAtLeast(shouldShowDescription ? 1 : 0);
366+
}
367+
368+
testWidgets('user options appear, disappear, and change correctly', (tester) async {
369+
final channel1 = eg.stream(name: 'mobile');
370+
final channel2 = eg.stream(name: 'mobile design');
371+
final channel3 = eg.stream(name: 'mobile dev help');
372+
final composeInputFinder = await setupToComposeInput(tester,
373+
channels: [channel1, channel2, channel3]);
374+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
375+
376+
// Options are filtered correctly for query
377+
// TODO(#226): Remove this extra edit when this bug is fixed.
378+
await tester.enterText(composeInputFinder, 'check #mobile ');
379+
await tester.enterText(composeInputFinder, 'check #mobile de');
380+
await tester.pumpAndSettle(); // async computation; options appear
381+
382+
checkChannelShown(channel1, expected: false);
383+
checkChannelShown(channel2, expected: true);
384+
checkChannelShown(channel3, expected: true);
385+
386+
// Finishing autocomplete updates compose box; causes options to disappear
387+
await tester.tap(find.text('mobile design'));
388+
await tester.pump();
389+
check(tester.widget<TextField>(composeInputFinder).controller!.text)
390+
.contains(channelLinkSyntax(channel2, store: store));
391+
checkChannelShown(channel1, expected: false);
392+
checkChannelShown(channel2, expected: false);
393+
checkChannelShown(channel3, expected: false);
394+
395+
// Then a new autocomplete intent brings up options again
396+
// TODO(#226): Remove this extra edit when this bug is fixed.
397+
await tester.enterText(composeInputFinder, 'check #mobile de');
398+
await tester.enterText(composeInputFinder, 'check #mobile dev');
399+
await tester.pumpAndSettle(); // async computation; options appear
400+
checkChannelShown(channel3, expected: true);
401+
402+
// Removing autocomplete intent causes options to disappear
403+
// TODO(#226): Remove one of these edits when this bug is fixed.
404+
await tester.enterText(composeInputFinder, 'check ');
405+
await tester.enterText(composeInputFinder, 'check');
406+
checkChannelShown(channel1, expected: false);
407+
checkChannelShown(channel2, expected: false);
408+
checkChannelShown(channel3, expected: false);
409+
410+
debugNetworkImageHttpClientProvider = null;
411+
});
412+
});
413+
357414
group('emoji', () {
358415
void checkEmojiShown(ExpectedEmoji option, {required bool expected}) {
359416
final (label, display) = option;

0 commit comments

Comments
 (0)