Skip to content

Prep for supporting set typing status #888

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 6 commits into from
Sep 16, 2024
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
4 changes: 3 additions & 1 deletion lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,9 @@ class TypingEvent extends Event {
@JsonEnum(fieldRename: FieldRename.snake)
enum TypingOp {
start,
stop
stop;

String toJson() => _$TypingOpEnumMap[this]!;
}

/// A Zulip event of type `reaction`, with op `add` or `remove`.
Expand Down
2 changes: 1 addition & 1 deletion lib/api/model/events.g.dart

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

2 changes: 1 addition & 1 deletion lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ class UserSettings {
Emojiset emojiset;

// TODO more, as needed. When adding a setting here, please also:
// (1) add it to the [UserSettingName] enum below
// (1) add it to the [UserSettingName] enum
// (2) then re-run the command to refresh the .g.dart files
// (3) handle the event that signals an update to the setting

Expand Down
8 changes: 5 additions & 3 deletions lib/api/route/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,16 @@ Future<SendMessageResult> sendMessage(
/// Which conversation to send a message to, in [sendMessage].
///
/// This is either a [StreamDestination] or a [DmDestination].
sealed class MessageDestination {}
sealed class MessageDestination {
const MessageDestination();
}

/// A conversation in a stream, for specifying to [sendMessage].
///
/// The server accepts a stream name as an alternative to a stream ID,
/// but this binding currently doesn't.
class StreamDestination extends MessageDestination {
StreamDestination(this.streamId, this.topic);
const StreamDestination(this.streamId, this.topic);

final int streamId;
final String topic;
Expand All @@ -237,7 +239,7 @@ class StreamDestination extends MessageDestination {
/// The server accepts a list of Zulip API emails as an alternative to
/// a list of user IDs, but this binding currently doesn't.
class DmDestination extends MessageDestination {
DmDestination({required this.userIds});
const DmDestination({required this.userIds});

final List<int> userIds;
}
Expand Down
30 changes: 30 additions & 0 deletions lib/api/route/typing.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import '../core.dart';
import '../model/events.dart';
import 'messages.dart';


/// https://zulip.com/api/set-typing-status
Future<void> setTypingStatus(ApiConnection connection, {
required TypingOp op,
required MessageDestination destination,
}) {
switch (destination) {
case StreamDestination():
final supportsTypeChannel = connection.zulipFeatureLevel! >= 248; // TODO(server-9)
final supportsStreamId = connection.zulipFeatureLevel! >= 215; // TODO(server-8)
return connection.post('setTypingStatus', (_) {}, 'typing', {
'op': RawParameter(op.toJson()),
'type': RawParameter(supportsTypeChannel ? 'channel' : 'stream'),
if (supportsStreamId) 'stream_id': destination.streamId
else 'to': [destination.streamId],
'topic': RawParameter(destination.topic),
});
case DmDestination():
final supportsDirect = connection.zulipFeatureLevel! >= 174; // TODO(server-7)
return connection.post('setTypingStatus', (_) {}, 'typing', {
'op': RawParameter(op.toJson()),
'type': RawParameter(supportsDirect ? 'direct' : 'private'),
'to': destination.userIds,
});
}
}
2 changes: 0 additions & 2 deletions lib/model/typing_status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart';

import '../api/model/events.dart';
import '../log.dart';
import 'narrow.dart';

/// The model for tracking the typing status organized by narrows.
Expand Down Expand Up @@ -40,7 +39,6 @@ class TypingStatus extends ChangeNotifier {

bool _addTypist(SendableNarrow narrow, int typistUserId) {
if (typistUserId == selfUserId) {
assert(debugLog('typing status: adding self as typist'));
return false;
}
final narrowTimerMap = _timerMapsByNarrow[narrow] ??= {};
Expand Down
36 changes: 18 additions & 18 deletions test/api/fake_api.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert';

import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -29,18 +30,18 @@ class _PreparedSuccess extends _PreparedResponse {
/// An [http.Client] that accepts and replays canned responses, for testing.
class FakeHttpClient extends http.BaseClient {

http.BaseRequest? lastRequest;
Iterable<http.BaseRequest> get requestHistory => _requestHistory;
List<http.BaseRequest> _requestHistory = [];

http.BaseRequest? takeLastRequest() {
final result = lastRequest;
lastRequest = null;
List<http.BaseRequest> takeRequests() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

How about also making the return type Iterable here?

Copy link
Member Author

@PIG208 PIG208 Aug 23, 2024

Choose a reason for hiding this comment

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

I didn't apply the same refactor there because takeRequests do not need to protect the list from modification, where List would be an accurate return type.

final result = _requestHistory;
_requestHistory = [];
return result;
}

_PreparedResponse? _nextResponse;
final Queue<_PreparedResponse> _preparedResponses = Queue();

// Please add more features to this mocking API as needed. For example:
// * preparing more than one request, and logging more than one request
// Please add more features to this mocking API as needed.

/// Prepare the response for the next request.
///
Expand All @@ -58,31 +59,31 @@ class FakeHttpClient extends http.BaseClient {
String? body,
Duration delay = Duration.zero,
}) {
assert(_nextResponse == null,
'FakeApiConnection.prepare was called while already expecting a request');
// TODO: Prevent a source of bugs by ensuring that there are no outstanding
// prepared responses when the test ends.
if (exception != null) {
assert(httpStatus == null && json == null && body == null);
_nextResponse = _PreparedException(exception: exception, delay: delay);
_preparedResponses.addLast(_PreparedException(exception: exception, delay: delay));
} else {
assert((json == null) || (body == null));
final String resolvedBody = switch ((body, json)) {
(var body?, _) => body,
(_, var json?) => jsonEncode(json),
_ => '',
};
_nextResponse = _PreparedSuccess(
_preparedResponses.addLast(_PreparedSuccess(
httpStatus: httpStatus ?? 200,
bytes: utf8.encode(resolvedBody),
delay: delay,
);
));
}
}

@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
lastRequest = request;
_requestHistory.add(request);

if (_nextResponse == null) {
if (_preparedResponses.isEmpty) {
throw FlutterError.fromParts([
ErrorSummary(
'An API request was attempted in a test when no response was prepared.'),
Expand All @@ -91,8 +92,7 @@ class FakeHttpClient extends http.BaseClient {
'call to [FakeApiConnection.prepare].'),
]);
}
final response = _nextResponse!;
_nextResponse = null;
final response = _preparedResponses.removeFirst();

final http.StreamedResponse Function() computation;
switch (response) {
Expand Down Expand Up @@ -205,9 +205,9 @@ class FakeApiConnection extends ApiConnection {
super.close();
}

http.BaseRequest? get lastRequest => client.lastRequest;
http.BaseRequest? get lastRequest => client._requestHistory.lastOrNull;

http.BaseRequest? takeLastRequest() => client.takeLastRequest();
List<http.BaseRequest> takeRequests() => client.takeRequests();

/// Prepare the response for the next request.
///
Expand Down
12 changes: 6 additions & 6 deletions test/api/route/messages_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ void main() {
test('smoke', () {
return FakeApiConnection.with_((connection) async {
await checkSendMessage(connection,
destination: StreamDestination(streamId, topic), content: content,
destination: const StreamDestination(streamId, topic), content: content,
queueId: 'abc:123',
localId: '456',
readBySender: true,
Expand All @@ -347,7 +347,7 @@ void main() {
test('to stream', () {
return FakeApiConnection.with_((connection) async {
await checkSendMessage(connection,
destination: StreamDestination(streamId, topic), content: content,
destination: const StreamDestination(streamId, topic), content: content,
readBySender: true,
expectedBodyFields: {
'type': 'stream',
Expand All @@ -362,7 +362,7 @@ void main() {
test('to DM conversation', () {
return FakeApiConnection.with_((connection) async {
await checkSendMessage(connection,
destination: DmDestination(userIds: userIds), content: content,
destination: const DmDestination(userIds: userIds), content: content,
readBySender: true,
expectedBodyFields: {
'type': 'direct',
Expand All @@ -376,7 +376,7 @@ void main() {
test('to DM conversation, with legacy type "private"', () {
return FakeApiConnection.with_(zulipFeatureLevel: 173, (connection) async {
await checkSendMessage(connection,
destination: DmDestination(userIds: userIds), content: content,
destination: const DmDestination(userIds: userIds), content: content,
readBySender: true,
expectedBodyFields: {
'type': 'private',
Expand All @@ -391,7 +391,7 @@ void main() {
test('when readBySender is null, sends a User-Agent we know the server will recognize', () {
return FakeApiConnection.with_((connection) async {
await checkSendMessage(connection,
destination: StreamDestination(streamId, topic), content: content,
destination: const StreamDestination(streamId, topic), content: content,
readBySender: null,
expectedBodyFields: {
'type': 'stream',
Expand All @@ -406,7 +406,7 @@ void main() {
test('legacy: when server does not support readBySender, sends a User-Agent the server will recognize', () {
return FakeApiConnection.with_(zulipFeatureLevel: 235, (connection) async {
await checkSendMessage(connection,
destination: StreamDestination(streamId, topic), content: content,
destination: const StreamDestination(streamId, topic), content: content,
readBySender: true,
expectedBodyFields: {
'type': 'stream',
Expand Down
101 changes: 101 additions & 0 deletions test/api/route/typing_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:checks/checks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/api/route/typing.dart';

import '../../stdlib_checks.dart';
import '../fake_api.dart';

void main() {
const streamId = 123;
const topic = 'topic';
const userIds = [101, 102, 103];

Future<void> checkSetTypingStatus(FakeApiConnection connection,
TypingOp op, {
required MessageDestination destination,
required Map<String, String> expectedBodyFields,
}) async {
connection.prepare(json: {});
await setTypingStatus(connection, op: op, destination: destination);
check(connection.lastRequest).isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/typing')
..bodyFields.deepEquals(expectedBodyFields);
}

Future<void> checkSetTypingStatusForTopic(TypingOp op, String expectedOp) {
return FakeApiConnection.with_((connection) {
return checkSetTypingStatus(connection, op,
destination: const StreamDestination(streamId, topic),
expectedBodyFields: {
'op': expectedOp,
'type': 'channel',
'stream_id': streamId.toString(),
'topic': topic,
});
});
}

test('send typing status start for topic', () {
return checkSetTypingStatusForTopic(TypingOp.start, 'start');
});

test('send typing status stop for topic', () {
return checkSetTypingStatusForTopic(TypingOp.stop, 'stop');
});

test('send typing status start for dm', () {
return FakeApiConnection.with_((connection) {
return checkSetTypingStatus(connection, TypingOp.start,
destination: const DmDestination(userIds: userIds),
expectedBodyFields: {
'op': 'start',
'type': 'direct',
'to': jsonEncode(userIds),
});
});
});

test('legacy: use "stream" instead of "channel"', () {
return FakeApiConnection.with_(zulipFeatureLevel: 247, (connection) {
return checkSetTypingStatus(connection, TypingOp.start,
destination: const StreamDestination(streamId, topic),
expectedBodyFields: {
'op': 'start',
'type': 'stream',
'stream_id': streamId.toString(),
'topic': topic,
});
});
});

test('legacy: use to=[streamId] instead of stream_id=streamId', () {
return FakeApiConnection.with_(zulipFeatureLevel: 214, (connection) {
return checkSetTypingStatus(connection, TypingOp.start,
destination: const StreamDestination(streamId, topic),
expectedBodyFields: {
'op': 'start',
'type': 'stream',
'to': jsonEncode([streamId]),
'topic': topic,
});
});
});

test('legacy: use "private" instead of "direct"', () {
return FakeApiConnection.with_(zulipFeatureLevel: 173, (connection) {
return checkSetTypingStatus(connection, TypingOp.start,
destination: const DmDestination(userIds: userIds),
expectedBodyFields: {
'op': 'start',
'type': 'private',
'to': jsonEncode(userIds),
});
});
});
}
10 changes: 5 additions & 5 deletions test/model/store_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ void main() {
await store.sendMessage(
destination: StreamDestination(stream.streamId, 'world'),
content: 'hello');
check(connection.takeLastRequest()).isA<http.Request>()
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/messages')
..bodyFields.deepEquals({
Expand Down Expand Up @@ -233,7 +233,7 @@ void main() {
}

void checkLastRequest() {
check(connection.takeLastRequest()).isA<http.Request>()
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/register');
}
Expand Down Expand Up @@ -337,7 +337,7 @@ void main() {
}

void checkLastRequest({required int lastEventId}) {
check(connection.takeLastRequest()).isA<http.Request>()
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('GET')
..url.path.equals('/api/v1/events')
..url.queryParameters.deepEquals({
Expand Down Expand Up @@ -487,14 +487,14 @@ void main() {
}

void checkLastRequestApns({required String token, required String appid}) {
check(connection.takeLastRequest()).isA<http.Request>()
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/users/me/apns_device_token')
..bodyFields.deepEquals({'token': token, 'appid': appid});
}

void checkLastRequestFcm({required String token}) {
check(connection.takeLastRequest()).isA<http.Request>()
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/users/me/android_gcm_reg_id')
..bodyFields.deepEquals({'token': token});
Expand Down