Skip to content

compose: Add narrowLink helper, to write links to Narrows #189

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
81 changes: 79 additions & 2 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import 'dart:math';

import '../api/model/narrow.dart';
import 'narrow.dart';
import 'store.dart';

//
// Put functions for nontrivial message-content generation in this file.
//
Expand Down Expand Up @@ -96,5 +100,78 @@ String wrapWithBacktickFence({required String content, String? infoString}) {
return resultBuffer.toString();
}

// TODO more, like /near links to messages in conversations
// (also to be used in quote-and-reply)
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());
}
34 changes: 32 additions & 2 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import 'api/fake_api.dart';

final Uri realmUrl = Uri.parse('https://chat.example/');

const String recentZulipVersion = '6.1';
const int recentZulipFeatureLevel = 164;
const String recentZulipVersion = '8.0';
const int recentZulipFeatureLevel = 185;
const int futureZulipFeatureLevel = 9999;

User user({int? userId, String? email, String? fullName}) {
Expand Down Expand Up @@ -57,6 +57,36 @@ final Account otherAccount = Account(

final User thirdUser = user(fullName: 'Third User', email: 'third@example', userId: 345);

ZulipStream stream({
int? streamId,
String? name,
String? description,
String? renderedDescription,
int? dateCreated,
int? firstMessageId,
bool? inviteOnly,
bool? isWebPublic,
bool? historyPublicToSubscribers,
int? messageRetentionDays,
int? streamPostPolicy,
int? canRemoveSubscribersGroupId,
}) {
return ZulipStream(
streamId: streamId ?? 123, // TODO generate example IDs
name: name ?? 'A stream', // TODO generate example names
description: description ?? 'A description', // TODO generate example descriptions
renderedDescription: renderedDescription ?? '<p>A description</p>', // TODO generate random
dateCreated: dateCreated ?? 1686774898,
firstMessageId: firstMessageId,
inviteOnly: inviteOnly ?? false,
isWebPublic: isWebPublic ?? false,
historyPublicToSubscribers: historyPublicToSubscribers ?? true,
messageRetentionDays: messageRetentionDays,
streamPostPolicy: streamPostPolicy ?? 1,
canRemoveSubscribersGroupId: canRemoveSubscribersGroupId ?? 123,
);
}

final _messagePropertiesBase = {
'is_me_message': false,
'last_edit_timestamp': null,
Expand Down
85 changes: 85 additions & 0 deletions test/model/compose_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import 'package:checks/checks.dart';
import 'package:test/scaffolding.dart';
import 'package:zulip/model/compose.dart';
import 'package:zulip/model/narrow.dart';

import '../example_data.dart' as eg;
import 'test_store.dart';

void main() {
group('wrapWithBacktickFence', () {
Expand Down Expand Up @@ -215,4 +219,85 @@ hello
''');
});
});

group('narrowLink', () {
test('AllMessagesNarrow', () {
final store = eg.store();
check(narrowLink(store, const AllMessagesNarrow()))
.equals(store.account.realmUrl.resolve('#narrow'));
check(narrowLink(store, const AllMessagesNarrow(), nearMessageId: 1))
.equals(store.account.realmUrl.resolve('#narrow/near/1'));
Copy link
Member

Choose a reason for hiding this comment

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

(resolve is good in these tests, though, because it lets the expected string start with a # which is a helpful cue to the reader.)

});

test('StreamNarrow / TopicNarrow', () {
void checkNarrow(String expectedFragment, {
required int streamId,
required String name,
String? topic,
int? nearMessageId,
}) {
assert(expectedFragment.startsWith('#'), 'wrong-looking expectedFragment');
final store = eg.store();
store.addStream(eg.stream(streamId: streamId, name: name));
final narrow = topic == null
? StreamNarrow(streamId)
: TopicNarrow(streamId, topic);
check(narrowLink(store, narrow, nearMessageId: nearMessageId))
.equals(store.account.realmUrl.resolve(expectedFragment));
}

checkNarrow(streamId: 1, name: 'announce', '#narrow/stream/1-announce');
checkNarrow(streamId: 378, name: 'api design', '#narrow/stream/378-api-design');
checkNarrow(streamId: 391, name: 'Outreachy', '#narrow/stream/391-Outreachy');
checkNarrow(streamId: 415, name: 'chat.zulip.org', '#narrow/stream/415-chat.2Ezulip.2Eorg');
checkNarrow(streamId: 419, name: 'français', '#narrow/stream/419-fran.C3.A7ais');
checkNarrow(streamId: 403, name: 'Hshs[™~}(.', '#narrow/stream/403-Hshs.5B.E2.84.A2~.7D.28.2E');
checkNarrow(streamId: 60, name: 'twitter', nearMessageId: 1570686, '#narrow/stream/60-twitter/near/1570686');

checkNarrow(streamId: 48, name: 'mobile', topic: 'Welcome screen UI',
'#narrow/stream/48-mobile/topic/Welcome.20screen.20UI');
checkNarrow(streamId: 243, name: 'mobile-team', topic: 'Podfile.lock clash #F92',
'#narrow/stream/243-mobile-team/topic/Podfile.2Elock.20clash.20.23F92');
checkNarrow(streamId: 377, name: 'translation/zh_tw', topic: '翻譯 "stream"',
'#narrow/stream/377-translation.2Fzh_tw/topic/.E7.BF.BB.E8.AD.AF.20.22stream.22');
checkNarrow(streamId: 42, name: 'Outreachy 2016-2017', topic: '2017-18 Stream?', nearMessageId: 302690,
'#narrow/stream/42-Outreachy-2016-2017/topic/2017-18.20Stream.3F/near/302690');
});

test('DmNarrow', () {
void checkNarrow(String expectedFragment, String legacyExpectedFragment, {
required List<int> allRecipientIds,
required int selfUserId,
int? nearMessageId,
}) {
assert(expectedFragment.startsWith('#'), 'wrong-looking expectedFragment');
final store = eg.store();
final narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: selfUserId);
check(narrowLink(store, narrow, nearMessageId: nearMessageId))
.equals(store.account.realmUrl.resolve(expectedFragment));
store.connection.zulipFeatureLevel = 176;
check(narrowLink(store, narrow, nearMessageId: nearMessageId))
.equals(store.account.realmUrl.resolve(legacyExpectedFragment));
}

checkNarrow(allRecipientIds: [1], selfUserId: 1,
'#narrow/dm/1-dm',
'#narrow/pm-with/1-pm');
checkNarrow(allRecipientIds: [1, 2], selfUserId: 1,
'#narrow/dm/1,2-dm',
'#narrow/pm-with/1,2-pm');
checkNarrow(allRecipientIds: [1, 2, 3], selfUserId: 1,
'#narrow/dm/1,2,3-group',
'#narrow/pm-with/1,2,3-group');
checkNarrow(allRecipientIds: [1, 2, 3, 4], selfUserId: 4,
'#narrow/dm/1,2,3,4-group',
'#narrow/pm-with/1,2,3,4-group');
checkNarrow(allRecipientIds: [1, 2], selfUserId: 1, nearMessageId: 12345,
'#narrow/dm/1,2-dm/near/12345',
'#narrow/pm-with/1,2-pm/near/12345');
});

// TODO other Narrow subclasses as we add them:
// starred, mentioned; searches; arbitrary
});
}