Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
442 changes: 413 additions & 29 deletions lib/model/autocomplete.dart

Large diffs are not rendered by default.

69 changes: 53 additions & 16 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ String userGroupMention(String userGroupName, {bool silent = false}) =>
// Corresponds to `topic_link_util.escape_invalid_stream_topic_characters`
// in Zulip web:
// https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L15-L34
const _channelAvoidedCharsReplacements = {
const _channelTopicAvoidedCharsReplacements = {
'`': '`',
'>': '>',
'*': '*',
Expand All @@ -198,41 +198,78 @@ const _channelAvoidedCharsReplacements = {
r'$$': '$$',
};

final _channelAvoidedCharsRegex = RegExp(r'[`>*&[\]]|\$\$');
final _channelTopicAvoidedCharsRegex = RegExp(r'[`>*&[\]]|\$\$');
final _channelTopicAvoidedCharsReplacementsRegex =
RegExp(_channelTopicAvoidedCharsReplacements.values.join('|'));

/// Markdown link for channel when the channel name includes characters that
String escapeChannelTopicAvoidedChars(String str) {
return str.replaceAllMapped(_channelTopicAvoidedCharsRegex,
(match) => _channelTopicAvoidedCharsReplacements[match[0]]!);
}

String unescapeChannelTopicAvoidedChars(String str) {
return str.replaceAllMapped(_channelTopicAvoidedCharsReplacementsRegex,
(match) => _channelTopicAvoidedCharsReplacements.map((k, v) => MapEntry(v, k))[match[0]]!);
}
Comment on lines +210 to +213
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose: Introduce `topicLink` function

Add this unescapeChannelTopicAvoidedChars in the later commit that makes use of it; it looks like it isn't used in this commit.


/// Markdown link for channel or topic whose name includes characters that
/// will break normal markdown rendering.
///
/// Refer to [_channelAvoidedCharsReplacements] for a complete list of
/// Refer to [_channelTopicAvoidedCharsReplacements] for a complete list of
/// these characters.
// Adopted from `topic_link_util.get_fallback_markdown_link` in Zulip web;
// https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L96-L108
String _channelFallbackMarkdownLink(ZulipStream channel, {
required PerAccountStore store,
String _channelTopicFallbackMarkdownLink(ZulipStream channel, PerAccountStore store, {
TopicName? topic,
}) {
final text = StringBuffer('#${escapeChannelTopicAvoidedChars(channel.name)}');
if (topic != null) {
text.write(' > ${escapeChannelTopicAvoidedChars(topic.displayName ?? store.realmEmptyTopicDisplayName)}');
}

final narrow = topic == null
? ChannelNarrow(channel.streamId) : TopicNarrow(channel.streamId, topic);
// Like Zulip web, we use a relative URL here, unlike [quoteAndReply] which
// uses an absolute URL. There'd be little benefit to an absolute URL here
// because this isn't a likely flow when a user wants something to copy-paste
// elsewhere: this flow normally produces `#**…**` syntax, which wouldn't work
// for that at all. And conversely, it's nice to keep reasonably short the
// markup that we put into the text box and which the user sees. Discussion:
// elsewhere: this flow normally produces `#**…**` or `#**…>…**` syntax,
// which wouldn't work for that at all. And conversely, it's nice to keep
// reasonably short the markup that we put into the text box and which the
// user sees. Discussion:
// https://chat.zulip.org/#narrow/channel/101-design/topic/.22quote.20message.22.20uses.20absolute.20URL.20instead.20of.20realm-relative/near/2325588
final relativeLink = '#${narrowLinkFragment(store, ChannelNarrow(channel.streamId))}';
final relativeLink = '#${narrowLinkFragment(store, narrow)}';

final text = '#${channel.name.replaceAllMapped(_channelAvoidedCharsRegex,
(match) => _channelAvoidedCharsReplacements[match[0]]!)}';
return inlineLink(text.toString(), relativeLink);
}

/// A #channel link syntax of a channel, like #**announce**.
///
/// A plain Markdown link will be used if the channel name includes some
/// characters that would break normal #**channel** rendering.
String channelLink(ZulipStream channel, {required PerAccountStore store}) {
if (_channelAvoidedCharsRegex.hasMatch(channel.name)) {
return _channelFallbackMarkdownLink(channel, store: store);
///
/// Set [isComplete] to `false` for an incomplete syntax such as "#**…>""
/// or "[#…](#…)>"; the one that will trigger a topic autocomplete
/// interaction for the channel.
Comment on lines +250 to +252
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's an extra quotation mark in in the first example? Also I think we can name isComplete more explicitly to what it does; how about:

Suggested change
/// Set [isComplete] to `false` for an incomplete syntax such as "#**…>""
/// or "[#…](#…)>"; the one that will trigger a topic autocomplete
/// interaction for the channel.
/// Set [pendingTopicAutocomplete] to `true` for an incomplete syntax:
/// #**…> or [#…](#…)>
/// i.e. the syntax that will trigger
/// a topic autocomplete interaction for the channel.

(and reverse the sign when renaming isComplete to pendingTopicAutocomplete)

String channelLink(ZulipStream channel, {
bool isComplete = true,
required PerAccountStore store,
}) {
if (_channelTopicAvoidedCharsRegex.hasMatch(channel.name)) {
return '${_channelTopicFallbackMarkdownLink(channel, store)}${isComplete ? '' : '>'}';
}
return '#**${channel.name}${isComplete ? '**' : '>'}';
}

/// A #channel>topic link syntax of a topic, like #**announce>GSoC**.
///
/// A plain Markdown link will be used if the channel or topic name includes
/// some characters that would break normal #**channel>topic** rendering.
String topicLink(ZulipStream channel, TopicName topic, {required PerAccountStore store}) {
if (_channelTopicAvoidedCharsRegex.hasMatch(channel.name)
|| _channelTopicAvoidedCharsRegex.hasMatch(topic.apiName)) {
return _channelTopicFallbackMarkdownLink(channel, topic: topic, store);
}
return '#**${channel.name}**';
return '#**${channel.name}>${topic.apiName}**';
}

/// https://spec.commonmark.org/0.30/#inline-link
Expand Down
1 change: 1 addition & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ class PerAccountStore extends PerAccountStoreBase with
_messages.handleUpdateMessageEvent(event);
unreads.handleUpdateMessageEvent(event);
topics.handleUpdateMessageEvent(event);
autocompleteViewManager.handleUpdateMessageEvent(event, store: this);

case DeleteMessageEvent():
assert(debugLog("server event: delete_message ${event.messageIds}"));
Expand Down
7 changes: 6 additions & 1 deletion lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
}

void _handleControllerChange() {
final newQuery = widget.autocompleteIntent()?.query;
var newQuery = widget.autocompleteIntent()?.query;
if (newQuery is TopicLinkAutocompleteQuery) newQuery = null; // TODO(#124)
final oldQuery = _viewModel?.query;
// First, tear down the old view-model if necessary.
if (_viewModel != null
Expand Down Expand Up @@ -241,6 +242,9 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
return;
}
replacementString = '${channelLink(channel, store: store)} ';
case TopicLinkAutocompleteChannelResult():
case TopicLinkAutocompleteTopicResult():
throw UnimplementedError(); // TODO(#124)
}

controller.value = intent.textEditingValue.replaced(
Expand All @@ -259,6 +263,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
MentionAutocompleteResult() => MentionAutocompleteItem(
option: option, narrow: narrow),
ChannelLinkAutocompleteResult() => _ChannelLinkAutocompleteItem(option: option),
TopicLinkAutocompleteResult() => throw UnimplementedError(), // TODO(#124)
EmojiAutocompleteResult() => EmojiAutocompleteItem(option: option),
};
return InkWell(
Expand Down
24 changes: 15 additions & 9 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,14 @@ class ComposeContentController extends ComposeController<ContentValidationError>
ComposeContentController({
super.text,
required super.store,
required this.narrow,
this.requireNotEmpty = true,
}) {
_update();
}

final Narrow narrow;

/// Whether to produce [ContentValidationError.empty].
final bool requireNotEmpty;

Expand Down Expand Up @@ -1595,8 +1598,8 @@ class _EditMessageComposeBoxBody extends _ComposeBoxBody {
}

sealed class ComposeBoxController {
ComposeBoxController({required PerAccountStore store})
: content = ComposeContentController(store: store);
ComposeBoxController({required PerAccountStore store, required Narrow narrow})
: content = ComposeContentController(store: store, narrow: narrow);

final ComposeContentController content;
final contentFocusNode = FocusNode();
Expand Down Expand Up @@ -1691,7 +1694,7 @@ enum ComposeTopicInteractionStatus {
}

class StreamComposeBoxController extends ComposeBoxController {
StreamComposeBoxController({required super.store})
StreamComposeBoxController({required super.store, required super.narrow})
: topic = ComposeTopicController(store: store);

final ComposeTopicController topic;
Expand Down Expand Up @@ -1722,24 +1725,26 @@ class StreamComposeBoxController extends ComposeBoxController {
}

class FixedDestinationComposeBoxController extends ComposeBoxController {
FixedDestinationComposeBoxController({required super.store});
FixedDestinationComposeBoxController({required super.store, required super.narrow});
}

class EditMessageComposeBoxController extends ComposeBoxController {
EditMessageComposeBoxController({
required super.store,
required super.narrow,
required this.messageId,
required this.originalRawContent,
required String? initialText,
}) : _content = ComposeContentController(
text: initialText,
store: store,
narrow: narrow,
// Editing to delete the content is a supported form of
// deletion: https://zulip.com/help/delete-a-message#delete-message-content
requireNotEmpty: false);

factory EditMessageComposeBoxController.empty(PerAccountStore store, int messageId) =>
EditMessageComposeBoxController(store: store, messageId: messageId,
factory EditMessageComposeBoxController.empty(PerAccountStore store, Narrow narrow, int messageId) =>
EditMessageComposeBoxController(store: store, narrow: narrow, messageId: messageId,
originalRawContent: null, initialText: null);

@override ComposeContentController get content => _content;
Expand Down Expand Up @@ -2121,6 +2126,7 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
controller.dispose();
_controller = EditMessageComposeBoxController(
store: store,
narrow: widget.narrow,
messageId: messageId,
originalRawContent: failedEdit.originalRawContent,
initialText: failedEdit.newContent,
Expand All @@ -2132,7 +2138,7 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
void _editFromRawContentFetch(int messageId) async {
final store = PerAccountStoreWidget.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
final emptyEditController = EditMessageComposeBoxController.empty(store, messageId);
final emptyEditController = EditMessageComposeBoxController.empty(store, widget.narrow, messageId);
setState(() {
controller.dispose();
_controller = emptyEditController;
Expand Down Expand Up @@ -2210,10 +2216,10 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
_controller?.dispose(); // `?.` because this might be the first call
switch (widget.narrow) {
case ChannelNarrow():
_controller = StreamComposeBoxController(store: store);
_controller = StreamComposeBoxController(store: store, narrow: widget.narrow);
case TopicNarrow():
case DmNarrow():
_controller = FixedDestinationComposeBoxController(store: store);
_controller = FixedDestinationComposeBoxController(store: store, narrow: widget.narrow);
case CombinedFeedNarrow():
case MentionsNarrow():
case StarredMessagesNarrow():
Expand Down
10 changes: 10 additions & 0 deletions test/model/autocomplete_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,13 @@ extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
extension ChannelLinkAutocompleteResultChecks on Subject<ChannelLinkAutocompleteResult> {
Subject<int> get channelId => has((r) => r.channelId, 'channelId');
}

extension TopicLinkAutocompleteChannelResultChecks on Subject<TopicLinkAutocompleteChannelResult> {
Subject<int> get channelId => has((r) => r.channelId, 'channelId');
}

extension TopicLinkAutocompleteTopicResultChecks on Subject<TopicLinkAutocompleteTopicResult> {
Subject<int> get channelId => has((r) => r.channelId, 'channelId');
Subject<TopicName> get topic => has((r) => r.topic, 'topic');
Subject<bool> get isNew => has((r) => r.isNew, 'isNew');
}
Loading