Skip to content

Commit 6d28107

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 18a7e2b commit 6d28107

File tree

4 files changed

+164
-5
lines changed

4 files changed

+164
-5
lines changed

lib/model/compose.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,17 @@ String fallbackMarkdownLink({
235235
return inlineLink(text.toString(), '#$linkFragment');
236236
}
237237

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 channelLink(ZulipStream channel, {required PerAccountStore store}) {
243+
if (_channelTopicFaultyCharsRegex.hasMatch(channel.name)) {
244+
return fallbackMarkdownLink(store: store, channel: channel);
245+
}
246+
return '#**${channel.name}**';
247+
}
248+
238249
/// https://spec.commonmark.org/0.30/#inline-link
239250
///
240251
/// The "link text" is made by enclosing [visibleText] in square brackets.

lib/widgets/autocomplete.dart

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
4545
}
4646

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

234242
controller.value = intent.textEditingValue.replaced(
@@ -246,7 +254,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
246254
final child = switch (option) {
247255
MentionAutocompleteResult() => MentionAutocompleteItem(
248256
option: option, narrow: narrow),
249-
ChannelLinkAutocompleteResult() => throw UnimplementedError(), // TODO(#124)
257+
ChannelLinkAutocompleteResult() => _ChannelLinkAutocompleteItem(option: option),
250258
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
251259
};
252260
return InkWell(
@@ -361,6 +369,51 @@ class MentionAutocompleteItem extends StatelessWidget {
361369
}
362370
}
363371

372+
class _ChannelLinkAutocompleteItem extends StatelessWidget {
373+
const _ChannelLinkAutocompleteItem({required this.option});
374+
375+
final ChannelLinkAutocompleteResult option;
376+
377+
@override
378+
Widget build(BuildContext context) {
379+
final store = PerAccountStoreWidget.of(context);
380+
final zulipLocalizations = ZulipLocalizations.of(context);
381+
final designVariables = DesignVariables.of(context);
382+
383+
final channel = store.streams[option.channelId];
384+
385+
// A null [Icon.icon] makes a blank space.
386+
IconData? icon;
387+
Color? iconColor;
388+
String label;
389+
if (channel != null) {
390+
icon = iconDataForStream(channel);
391+
iconColor = colorSwatchFor(context, store.subscriptions[channel.streamId])
392+
.iconOnPlainBackground;
393+
label = channel.name;
394+
} else {
395+
icon = null;
396+
iconColor = null;
397+
label = zulipLocalizations.unknownChannelName;
398+
}
399+
400+
final labelWidget = Text(label,
401+
overflow: TextOverflow.ellipsis,
402+
style: TextStyle(
403+
fontSize: 18, height: 20 / 18,
404+
fontStyle: channel == null ? FontStyle.italic : FontStyle.normal,
405+
color: designVariables.contextMenuItemLabel,
406+
).merge(weightVariableTextStyle(context, wght: 600)));
407+
408+
return Padding(
409+
padding: EdgeInsetsDirectional.fromSTEB(4, 4, 8, 4),
410+
child: Row(spacing: 6, children: [
411+
SizedBox.square(dimension: 36, child: Icon(size: 18, color: iconColor, icon)),
412+
Expanded(child: labelWidget), // TODO(#1945): show channel description
413+
]));
414+
}
415+
}
416+
364417
class _EmojiAutocompleteItem extends StatelessWidget {
365418
const _EmojiAutocompleteItem({required this.option});
366419

test/model/compose_test.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,48 @@ hello
367367
.throws<AssertionError>();
368368
});
369369

370+
group('channel link', () {
371+
test('channels with normal names', () async {
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+
await store.addStreams(channels);
382+
383+
check(channelLink(channels[0], store: store)).equals('#**mobile**');
384+
check(channelLink(channels[1], store: store)).equals('#**dev-ops**');
385+
check(channelLink(channels[2], store: store)).equals('#**ui/ux**');
386+
check(channelLink(channels[3], store: store)).equals('#**api_v3**');
387+
check(channelLink(channels[4], store: store)).equals('#**build+test**');
388+
check(channelLink(channels[5], store: store)).equals('#**init()**');
389+
});
390+
391+
test('channels with names containing faulty characters', () async {
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+
await store.addStreams(channels);
402+
403+
check(channelLink(channels[1 - 1], store: store)).equals('[&#96;code&#96;](#narrow/channel/1-.60code.60)');
404+
check(channelLink(channels[2 - 1], store: store)).equals('[score &gt; 90](#narrow/channel/2-score-.3E-90)');
405+
check(channelLink(channels[3 - 1], store: store)).equals('[A&#42;](#narrow/channel/3-A*)');
406+
check(channelLink(channels[4 - 1], store: store)).equals('[R&amp;D](#narrow/channel/4-R.26D)');
407+
check(channelLink(channels[5 - 1], store: store)).equals('[UI &#91;v2&#93;](#narrow/channel/5-UI-.5Bv2.5D)');
408+
check(channelLink(channels[6 - 1], store: store)).equals('[Save &#36;&#36;](#narrow/channel/6-Save-.24.24)');
409+
});
410+
});
411+
370412
test('inlineLink', () {
371413
check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)');
372414
check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()');

test/widgets/autocomplete_test.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ 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/icons.dart';
1920
import 'package:zulip/widgets/image.dart';
2021
import 'package:zulip/widgets/message_list.dart';
2122
import 'package:zulip/widgets/user.dart';
@@ -355,6 +356,58 @@ void main() {
355356
});
356357
});
357358

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+
365+
testWidgets('user options appear, disappear, and change correctly', (tester) async {
366+
final channel1 = eg.stream(name: 'mobile');
367+
final channel2 = eg.stream(name: 'mobile design');
368+
final channel3 = eg.stream(name: 'mobile dev help');
369+
final composeInputFinder = await setupToComposeInput(tester,
370+
channels: [channel1, channel2, channel3]);
371+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
372+
373+
// Options are filtered correctly for query.
374+
// TODO(#226): Remove this extra edit when this bug is fixed.
375+
await tester.enterText(composeInputFinder, 'check #mobile ');
376+
await tester.enterText(composeInputFinder, 'check #mobile de');
377+
await tester.pumpAndSettle(); // async computation; options appear
378+
379+
checkChannelShown(channel1, expected: false);
380+
checkChannelShown(channel2, expected: true);
381+
checkChannelShown(channel3, expected: true);
382+
383+
// Finishing autocomplete updates compose box; causes options to disappear.
384+
await tester.tap(find.text('mobile design'));
385+
await tester.pump();
386+
check(tester.widget<TextField>(composeInputFinder).controller!.text)
387+
.contains(channelLink(channel2, store: store));
388+
checkChannelShown(channel1, expected: false);
389+
checkChannelShown(channel2, expected: false);
390+
checkChannelShown(channel3, expected: false);
391+
392+
// Then a new autocomplete intent brings up options again.
393+
// TODO(#226): Remove this extra edit when this bug is fixed.
394+
await tester.enterText(composeInputFinder, 'check #mobile de');
395+
await tester.enterText(composeInputFinder, 'check #mobile dev');
396+
await tester.pumpAndSettle(); // async computation; options appear
397+
checkChannelShown(channel3, expected: true);
398+
399+
// Removing autocomplete intent causes options to disappear.
400+
// TODO(#226): Remove this extra edit when this bug is fixed.
401+
await tester.enterText(composeInputFinder, 'check ');
402+
await tester.enterText(composeInputFinder, 'check');
403+
checkChannelShown(channel1, expected: false);
404+
checkChannelShown(channel2, expected: false);
405+
checkChannelShown(channel3, expected: false);
406+
407+
debugNetworkImageHttpClientProvider = null;
408+
});
409+
});
410+
358411
group('emoji', () {
359412
void checkEmojiShown(ExpectedEmoji option, {required bool expected}) {
360413
final (label, display) = option;

0 commit comments

Comments
 (0)