Skip to content
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

Distinguish a TopicName type from plain String #1266

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
431ef8a
api: Explicitly ignore topics on DMs
gnprice Jan 8, 2025
02002ed
autocomplete: Simplify updating input on topic autocomplete
gnprice Jan 8, 2025
f4783f1
compose [nfc]: Abstract out ComposeTopicController.setTopic
gnprice Jan 8, 2025
15cc6ba
test [nfc]: Centralize a helper eg.unreadChannelMsgs
gnprice Jan 8, 2025
dcf3e40
api [nfc]: Introduce TopicName extension type
gnprice Jan 8, 2025
141c359
test [nfc]: Add an "eg.t" helper to make a TopicName; use it
gnprice Jan 8, 2025
6c19203
test [nfc]: Add a helper eg.topicNarrow; use it
gnprice Jan 8, 2025
28945cd
test [nfc]: Accept TopicName when constructing example move events
gnprice Jan 8, 2025
688b2e5
test [nfc]: Explicitly convert various other topics to TopicName
gnprice Jan 8, 2025
d05f5df
test [nfc]: Leave a few topics as explicit strings
gnprice Jan 8, 2025
f008a95
api [nfc]: Use TopicName in initial-snapshot types
gnprice Jan 8, 2025
c769307
api [nfc]: Use TopicName in event types
gnprice Jan 8, 2025
846fec8
api [nfc]: Use TopicName in one remaining result type
gnprice Jan 8, 2025
4f8e933
api [nfc]: Use TopicName type in decoding message edit history
gnprice Jan 8, 2025
78ad40e
api [nfc]: Use TopicName type in an API-interpreting static method
gnprice Jan 8, 2025
3ba4cf7
api [nfc]: Use TopicName in notification types
gnprice Jan 8, 2025
909040c
model [nfc]: Use TopicName type across our model code
gnprice Jan 8, 2025
cd5f4a5
api [nfc]: Use TopicName type in ApiNarrow
gnprice Jan 8, 2025
9e97109
api [nfc]: Use TopicName type across request parameters
gnprice Jan 8, 2025
fca6fb1
api [nfc]: Introduce TopicName.apiName, and use for making API requests
gnprice Jan 8, 2025
9ddd691
api [nfc]: Use TopicName.apiName for constructing narrow links
gnprice Jan 8, 2025
6973bbc
api [nfc]: Use TopicName.apiName for identifying resolve/unresolve moves
gnprice Jan 8, 2025
91c087c
notif [nfc]: Use TopicName.apiName for encoding NotificationOpenPayload
gnprice Jan 8, 2025
1e5d2a3
api [nfc]: Factor out TopicName.canonicalize
gnprice Jan 8, 2025
bcb1b5b
api [nfc]: Introduce TopicName.displayName, and use where needed
gnprice Jan 8, 2025
e2b9b57
api [nfc]: Seal the TopicName migration by making non-transparent
gnprice Jan 8, 2025
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
12 changes: 6 additions & 6 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ class UserTopicEvent extends Event {
String get type => 'user_topic';

final int streamId;
final String topicName;
final TopicName topicName;
final int lastUpdated;
final UserTopicVisibilityPolicy visibilityPolicy;

Expand Down Expand Up @@ -725,9 +725,9 @@ class UpdateMessageEvent extends Event {
final PropagateMode? propagateMode;

@JsonKey(name: 'orig_subject')
final String? origTopic;
final TopicName? origTopic;
@JsonKey(name: 'subject')
final String? newTopic;
final TopicName? newTopic;

// final List<TopicLink> topicLinks; // TODO handle

Expand Down Expand Up @@ -788,7 +788,7 @@ class DeleteMessageEvent extends Event {
@MessageTypeConverter()
final MessageType messageType;
final int? streamId;
final String? topic;
final TopicName? topic;

DeleteMessageEvent({
required super.id,
Expand Down Expand Up @@ -924,7 +924,7 @@ class UpdateMessageFlagsMessageDetail {
final bool? mentioned;
final List<int>? userIds;
final int? streamId;
final String? topic;
final TopicName? topic;

UpdateMessageFlagsMessageDetail({
required this.type,
Expand Down Expand Up @@ -1002,7 +1002,7 @@ class TypingEvent extends Event {
@JsonKey(name: 'recipients', fromJson: _recipientIdsFromJson)
final List<int>? recipientIds;
final int? streamId;
final String? topic;
final TopicName? topic;

TypingEvent({
required super.id,
Expand Down
22 changes: 16 additions & 6 deletions lib/api/model/events.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ class UserSettings {
@JsonSerializable(fieldRename: FieldRename.snake)
class UserTopicItem {
final int streamId;
final String topicName;
final TopicName topicName;
final int lastUpdated;
@JsonKey(unknownEnumValue: UserTopicVisibilityPolicy.unknown)
final UserTopicVisibilityPolicy visibilityPolicy;
Expand Down Expand Up @@ -310,7 +310,7 @@ class UnreadDmSnapshot {
/// An item in [UnreadMessagesSnapshot.channels].
@JsonSerializable(fieldRename: FieldRename.snake)
class UnreadChannelSnapshot {
final String topic;
final TopicName topic;
final int streamId;
final List<int> unreadMessageIds;

Expand Down
4 changes: 2 additions & 2 deletions lib/api/model/initial_snapshot.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 52 additions & 12 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -556,11 +556,11 @@ sealed class Message {
final String senderFullName;
final int senderId;
final String senderRealmStr;
@JsonKey(name: 'subject')
String topic;

/// Poll data if "submessages" describe a poll, `null` otherwise.
@JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson)
Poll? poll;

final int timestamp;
String get type;

Expand Down Expand Up @@ -613,7 +613,6 @@ sealed class Message {
required this.senderFullName,
required this.senderId,
required this.senderRealmStr,
required this.topic,
required this.timestamp,
required this.flags,
required this.matchContent,
Expand Down Expand Up @@ -656,6 +655,38 @@ enum MessageFlag {
String toJson() => _$MessageFlagEnumMap[this]!;
}

/// The name of a Zulip topic.
// TODO(dart): Can we forbid calling Object members on this extension type?
// (The lack of "implements Object" ought to do that, but doesn't.)
// In particular an interpolation "foo > $topic" is a bug we'd like to catch.
// TODO(dart): Can we forbid using this extension type as a key in a Map?
// (The lack of "implements Object" arguably should do that, but doesn't.)
// Using as a Map key is almost certainly a bug because it won't case-fold;
// see for example #739, #980, #1205.
extension type const TopicName(String _value) {
/// The string this topic is identified by in the Zulip API.
///
/// This should be used in constructing HTTP requests to the server,
/// but rarely for other purposes. See [displayName] and [canonicalize].
String get apiName => _value;

/// The string this topic is displayed as to the user in our UI.
///
/// At the moment this always equals [apiName].
/// In the future this will become null for the "general chat" topic (#1250),
/// so that UI code can identify when it needs to represent the topic
/// specially in the way prescribed for "general chat".
// TODO(#1250) carry out that plan
String get displayName => _value;

/// The key to use for "same topic as" comparisons.
String canonicalize() => apiName.toLowerCase();

TopicName.fromJson(this._value);

String toJson() => apiName;
}

@JsonSerializable(fieldRename: FieldRename.snake)
class StreamMessage extends Message {
@override
Expand All @@ -667,8 +698,16 @@ class StreamMessage extends Message {
// invalidated.
@JsonKey(required: true, disallowNullValue: true)
String? displayRecipient;

int streamId;

// The topic/subject is documented to be present on DMs too, just empty.
// We ignore it on DMs; if a future server introduces distinct topics in DMs,
// that will need new UI that we'll design then as part of that feature,
// and ignoring the topics seems as good a fallback behavior as any.
@JsonKey(name: 'subject')
TopicName topic;

StreamMessage({
required super.client,
required super.content,
Expand All @@ -683,13 +722,13 @@ class StreamMessage extends Message {
required super.senderFullName,
required super.senderId,
required super.senderRealmStr,
required super.topic,
required super.timestamp,
required super.flags,
required super.matchContent,
required super.matchTopic,
required this.displayRecipient,
required this.streamId,
required this.topic,
});

factory StreamMessage.fromJson(Map<String, dynamic> json) =>
Expand Down Expand Up @@ -786,7 +825,6 @@ class DmMessage extends Message {
required super.senderFullName,
required super.senderId,
required super.senderRealmStr,
required super.topic,
required super.timestamp,
required super.flags,
required super.matchContent,
Expand Down Expand Up @@ -817,14 +855,14 @@ enum MessageEditState {
/// The Zulip "resolved topics" feature is implemented by renaming the topic;
/// but for purposes of [Message.editState], we want to ignore such renames.
/// This method identifies topic moves that should be ignored in that context.
static bool topicMoveWasResolveOrUnresolve(String topic, String prevTopic) {
if (topic.startsWith(_resolvedTopicPrefix)
&& topic.substring(_resolvedTopicPrefix.length) == prevTopic) {
static bool topicMoveWasResolveOrUnresolve(TopicName topic, TopicName prevTopic) {
if (topic.apiName.startsWith(_resolvedTopicPrefix)
&& topic.apiName.substring(_resolvedTopicPrefix.length) == prevTopic.apiName) {
return true;
}

if (prevTopic.startsWith(_resolvedTopicPrefix)
&& prevTopic.substring(_resolvedTopicPrefix.length) == topic) {
if (prevTopic.apiName.startsWith(_resolvedTopicPrefix)
&& prevTopic.apiName.substring(_resolvedTopicPrefix.length) == topic.apiName) {
return true;
}

Expand Down Expand Up @@ -857,8 +895,10 @@ enum MessageEditState {
}

// TODO(server-5) prev_subject was the old name of prev_topic on pre-5.0 servers
final prevTopic = (entry['prev_topic'] ?? entry['prev_subject']) as String?;
final topic = entry['topic'] as String?;
final prevTopicStr = (entry['prev_topic'] ?? entry['prev_subject']) as String?;
final prevTopic = prevTopicStr == null ? null : TopicName.fromJson(prevTopicStr);
final topicStr = entry['topic'] as String?;
final topic = topicStr == null ? null : TopicName.fromJson(topicStr);
if (prevTopic != null) {
// TODO(server-5) pre-5.0 servers do not have the 'topic' field
if (topic == null) {
Expand Down
6 changes: 2 additions & 4 deletions lib/api/model/model.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 35 additions & 3 deletions lib/api/model/narrow.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';

import 'model.dart';

part 'narrow.g.dart';

typedef ApiNarrow = List<ApiNarrowElement>;
Expand All @@ -26,7 +28,37 @@ ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) {
/// please add more as needed.
sealed class ApiNarrowElement {
String get operator;
Object get operand;

/// The operand of this narrow filter.
///
/// The base-class getter [ApiNarrowElement.operand] returns `dynamic`,
/// and its value should only be used for encoding as JSON, for use in a
/// request to the Zulip server.
///
/// For any operations that depend more specifically on the operand's type,
/// do not use run-time type checks on the value of [operand]; instead, make
/// a run-time type check (e.g. with `switch`) on the [ApiNarrowElement]
/// itself, and use the [operand] getter of the specific subtype.
///
/// That makes a difference because [ApiNarrowTopic.operand] has type
/// [TopicName]; at runtime a [TopicName] is indistinguishable from [String],
/// but an [ApiNarrowTopic] can still be distinguished from other subclasses.
//
// We can't just write [Object] here; if we do, the compiler rejects the
// override in ApiNarrowTopic because TopicName can't be assigned to Object.
// The reason that could be bad is that a caller of [ApiNarrowElement.operand]
// could take the result and call Object members on it, like toString, even
// though TopicName doesn't declare those members.
//
// In this case that's fine because the only plausible thing to do with
// a generic [ApiNarrowElement.operand] is to encode it as JSON anyway,
// which behaves just fine on TopicName.
//
// ... Even if it weren't fine, in the case of Object this protection is
// thoroughly undermined already: code that has a TopicName can call Object
// members on it directly. See comments at [TopicName].
dynamic get operand; // see justification for `dynamic` above

final bool negated;

ApiNarrowElement({this.negated = false});
Expand Down Expand Up @@ -54,12 +86,12 @@ class ApiNarrowStream extends ApiNarrowElement {
class ApiNarrowTopic extends ApiNarrowElement {
@override String get operator => 'topic';

@override final String operand;
@override final TopicName operand;

ApiNarrowTopic(this.operand, {super.negated});

factory ApiNarrowTopic.fromJson(Map<String, dynamic> json) => ApiNarrowTopic(
json['operand'] as String,
TopicName.fromJson(json['operand'] as String),
negated: json['negated'] as bool? ?? false,
);
}
Expand Down
Loading