forked from zulip/zulip-flutter
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcompose.dart
177 lines (158 loc) · 6.46 KB
/
compose.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import 'dart:math';
import '../api/model/narrow.dart';
import 'narrow.dart';
import 'store.dart';
//
// Put functions for nontrivial message-content generation in this file.
//
// If it's complicated enough to need tests, it should go in here.
//
// https://spec.commonmark.org/0.30/#fenced-code-blocks
final RegExp _openingBacktickFenceRegex = (() {
// Recognize a fence with "up to three spaces of indentation".
// Servers don't recognize fences that start with spaces, as of Server 7.0:
// https://chat.zulip.org/#narrow/stream/6-frontend/topic/quote-and-reply.20fence.20length/near/1588273
// but that's a bug, since those fences are valid in the spec.
// Still, it's harmless to make our own fence longer even if the server
// wouldn't notice the internal fence that we're steering clear of,
// and if servers *do* start following the spec by noticing indented internal
// fences, then this client behavior will be nice.
const lineStart = r'^ {0,3}';
// The backticks, captured so we can see how many.
const backticks = r'(`{3,})';
// The "info string" plus (meaningless) leading or trailing spaces or tabs.
// It can't contain backticks.
const trailing = r'[^`]*$';
return RegExp(lineStart + backticks + trailing, multiLine: true);
})();
/// The shortest backtick fence that's longer than any in [content].
///
/// Expressed as a number of backticks.
///
/// Use this for quote-and-reply or anything else that requires wrapping
/// Markdown in a backtick fence.
///
/// See the CommonMark spec, which Zulip servers should but don't always follow:
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
int getUnusedBacktickFenceLength(String content) {
final matches = _openingBacktickFenceRegex.allMatches(content);
int result = 3;
for (final match in matches) {
result = max(result, match[1]!.length + 1);
}
return result;
}
/// Wrap Markdown [content] with opening and closing backtick fences.
///
/// For example, for this Markdown:
///
/// ```javascript
/// console.log('Hello world!');
/// ```
///
/// this function, with `infoString: 'quote'`, gives
///
/// ````quote
/// ```javascript
/// console.log('Hello world!');
/// ```
/// ````
///
/// See the CommonMark spec, which Zulip servers should but don't always follow:
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
// In [content], indented code blocks
// ( https://spec.commonmark.org/0.30/#indented-code-blocks )
// and code blocks fenced with tildes should make no difference to the
// backtick fences we choose here; this function ignores them.
String wrapWithBacktickFence({required String content, String? infoString}) {
assert(infoString == null || !infoString.contains('`'));
assert(infoString == null || infoString.trim() == infoString);
StringBuffer resultBuffer = StringBuffer();
// CommonMark doesn't require closing fences to be paired:
// https://github.com/zulip/zulip-flutter/pull/179#discussion_r1228712591
//
// - We need our opening fence to be long enough that it won't be closed by
// any fence in the content.
// - We need our closing fence to be long enough that it will close any
// outstanding opening fences in the content.
final fenceLength = getUnusedBacktickFenceLength(content);
resultBuffer.write('`' * fenceLength);
if (infoString != null) {
resultBuffer.write(infoString);
}
resultBuffer.write('\n');
resultBuffer.write(content);
if (content.isNotEmpty && !content.endsWith('\n')) {
resultBuffer.write('\n');
}
resultBuffer.write('`' * fenceLength);
resultBuffer.write('\n');
return resultBuffer.toString();
}
const _hashReplacements = {
"%": ".",
"(": ".28",
")": ".29",
".": ".2E",
};
final _encodeHashComponentRegex = RegExp(r'[%().]');
// Corresponds to encodeHashComponent in Zulip web;
// see web/shared/src/internal_url.ts.
String _encodeHashComponent(String str) {
return Uri.encodeComponent(str)
.replaceAllMapped(_encodeHashComponentRegex, (Match m) => _hashReplacements[m[0]!]!);
}
/// A URL to the given [Narrow], on `store`'s realm.
///
/// To include /near/{messageId} in the link, pass a non-null [nearMessageId].
// Why take [nearMessageId] in a param, instead of looking for it in [narrow]?
//
// A reasonable question: after all, the "near" part of a near link (e.g., for
// quote-and-reply) does take the same form as other operator/operand pairs
// that we represent with [ApiNarrowElement]s, like "/stream/48-mobile".
//
// But unlike those other elements, we choose not to give the "near" element
// an [ApiNarrowElement] representation, because it doesn't have quite that role:
// it says where to look in a list of messages, but it doesn't filter the list down.
// In fact, from a brief look at server code, it seems to be *ignored*
// if you include it in the `narrow` param in get-messages requests.
// When you want to point the server to a location in a message list, you
// you do so by passing the `anchor` param.
Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
final apiNarrow = narrow.apiEncode();
final fragment = StringBuffer('narrow');
for (ApiNarrowElement element in apiNarrow) {
fragment.write('/');
if (element.negated) {
fragment.write('-');
}
if (element is ApiNarrowDm) {
final supportsOperatorDm = store.connection.zulipFeatureLevel! >= 177; // TODO(server-7)
element = element.resolve(legacy: !supportsOperatorDm);
}
fragment.write('${element.operator}/');
switch (element) {
case ApiNarrowStream():
final streamId = element.operand;
final name = store.streams[streamId]?.name ?? 'unknown';
final slugifiedName = _encodeHashComponent(name.replaceAll(' ', '-'));
fragment.write('$streamId-$slugifiedName');
case ApiNarrowTopic():
fragment.write(_encodeHashComponent(element.operand));
case ApiNarrowDmModern():
final suffix = element.operand.length >= 3 ? 'group' : 'dm';
fragment.write('${element.operand.join(',')}-$suffix');
case ApiNarrowPmWith():
final suffix = element.operand.length >= 3 ? 'group' : 'pm';
fragment.write('${element.operand.join(',')}-$suffix');
case ApiNarrowDm():
assert(false, 'ApiNarrowDm should have been resolved');
case ApiNarrowMessageId():
fragment.write(element.operand.toString());
}
}
if (nearMessageId != null) {
fragment.write('/near/$nearMessageId');
}
return store.account.realmUrl.replace(fragment: fragment.toString());
}