Skip to content

Commit 5a8171d

Browse files
committed
channel: Finish channel link autocomplete for compose box
Fixes-partly: #124
1 parent 106ab8f commit 5a8171d

File tree

2 files changed

+126
-5
lines changed

2 files changed

+126
-5
lines changed

lib/model/compose.dart

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

188+
// Corresponds to escape_invalid_stream_topic_characters in Zulip web;
189+
// see web/src/topic_link_util.ts.
190+
const _channelTopicFaultyCharsReplacements = {
191+
'`': '`',
192+
'>': '>',
193+
'*': '*',
194+
'&': '&',
195+
'[': '[',
196+
']': ']',
197+
r'$$': '$$',
198+
};
199+
200+
final _channelTopicFaultyCharsRegex = RegExp(r'[`>*&[\]]|(?:\$\$)');
201+
202+
// Corresponds to get_fallback_markdown_link in Zulip web;
203+
// see web/src/topic_link_util.ts.
204+
String fallbackMarkdownLink({
205+
required PerAccountStore store,
206+
required ZulipStream channel,
207+
TopicName? topic,
208+
int? nearMessageId,
209+
}) {
210+
assert(nearMessageId == null || topic != null);
211+
212+
String replaceFaultyChars(String str) {
213+
return str.replaceAllMapped(_channelTopicFaultyCharsRegex,
214+
(match) => _channelTopicFaultyCharsReplacements[match[0]]!);
215+
}
216+
217+
final StringBuffer text = StringBuffer(replaceFaultyChars(channel.name));
218+
if (topic != null) {
219+
text.write(' > ${replaceFaultyChars(topic.displayName ?? store.realmEmptyTopicDisplayName)}');
220+
}
221+
if (nearMessageId != null) {
222+
text.write(' @ 💬');
223+
}
224+
225+
final narrow = topic == null
226+
? ChannelNarrow(channel.streamId) : TopicNarrow(channel.streamId, topic);
227+
final linkFragment = narrowLinkFragment(store, narrow, nearMessageId: nearMessageId);
228+
229+
return inlineLink(text.toString(), '#$linkFragment');
230+
}
231+
232+
String channelLinkSyntax(ZulipStream channel, {
233+
required PerAccountStore store,
234+
}) {
235+
if (_channelTopicFaultyCharsRegex.hasMatch(channel.name)) {
236+
return fallbackMarkdownLink(store: store, channel: channel);
237+
}
238+
return '#**${channel.name}**';
239+
}
240+
188241
/// https://spec.commonmark.org/0.30/#inline-link
189242
///
190243
/// The "link text" is made by enclosing [visibleText] in square brackets.

lib/widgets/autocomplete.dart

Lines changed: 73 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(
@@ -415,6 +425,64 @@ class _EmojiAutocompleteItem extends StatelessWidget {
415425
}
416426
}
417427

428+
class _ChannelLinkAutocompleteItem extends StatelessWidget {
429+
const _ChannelLinkAutocompleteItem({required this.option});
430+
431+
final ChannelLinkAutocompleteResult option;
432+
433+
@override
434+
Widget build(BuildContext context) {
435+
final store = PerAccountStoreWidget.of(context);
436+
final zulipLocalizations = ZulipLocalizations.of(context);
437+
final designVariables = DesignVariables.of(context);
438+
439+
final channel = store.streams[option.channelId];
440+
441+
// A null [Icon.icon] makes a blank space.
442+
IconData? icon;
443+
Color? iconColor;
444+
String label;
445+
String? subLabel;
446+
if (channel != null) {
447+
icon = iconDataForStream(channel);
448+
iconColor = colorSwatchFor(context, store.subscriptions[channel.streamId])
449+
.iconOnPlainBackground;
450+
label = channel.name;
451+
subLabel = channel.renderedDescription.isNotEmpty
452+
? channel.renderedDescription : null;
453+
} else {
454+
icon = null;
455+
iconColor = null;
456+
label = zulipLocalizations.unknownChannelName;
457+
subLabel = null;
458+
}
459+
460+
final labelWidget = Text(label,
461+
overflow: TextOverflow.ellipsis,
462+
style: TextStyle(
463+
fontSize: 18, height: 20 / 18,
464+
color: designVariables.contextMenuItemLabel,
465+
).merge(weightVariableTextStyle(context, wght: 600)));
466+
467+
final subLabelWidget = subLabel == null ? null
468+
: ZulipRenderedContent(parseContent(channel!.renderedDescription),
469+
style: TextStyle(fontSize: 14, height: 16 / 14,
470+
overflow: TextOverflow.ellipsis,
471+
color: designVariables.contextMenuItemMeta));
472+
473+
return Padding(
474+
padding: EdgeInsetsGeometry.fromSTEB(12, 4, 10, 4),
475+
child: Row(spacing: 10, children: [
476+
SizedBox.square(dimension: 24,
477+
child: Icon(size: 18, color: iconColor, icon)),
478+
Expanded(child: Column(
479+
crossAxisAlignment: CrossAxisAlignment.start,
480+
children: [labelWidget, ?subLabelWidget])),
481+
]),
482+
);
483+
}
484+
}
485+
418486
class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicAutocompleteResult> {
419487
const TopicAutocomplete({
420488
super.key,

0 commit comments

Comments
 (0)