diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index ec270c3d3c..31d7a47917 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -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`. diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 7aed0879c7..39f200e25a 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -612,7 +612,7 @@ Map _$TypingEventToJson(TypingEvent instance) => { 'id': instance.id, 'type': instance.type, - 'op': _$TypingOpEnumMap[instance.op]!, + 'op': instance.op, 'message_type': const MessageTypeConverter().toJson(instance.messageType), 'sender_id': instance.senderId, 'recipients': instance.recipientIds, diff --git a/lib/api/route/typing.dart b/lib/api/route/typing.dart new file mode 100644 index 0000000000..b9f8503172 --- /dev/null +++ b/lib/api/route/typing.dart @@ -0,0 +1,30 @@ +import '../core.dart'; +import '../model/events.dart'; +import 'messages.dart'; + + +/// https://zulip.com/api/set-typing-status +Future 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, + }); + } +} diff --git a/test/api/route/typing_test.dart b/test/api/route/typing_test.dart new file mode 100644 index 0000000000..0d933e6f54 --- /dev/null +++ b/test/api/route/typing_test.dart @@ -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 checkSetTypingStatus(FakeApiConnection connection, + TypingOp op, { + required MessageDestination destination, + required Map expectedBodyFields, + }) async { + connection.prepare(json: {}); + await setTypingStatus(connection, op: op, destination: destination); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/typing') + ..bodyFields.deepEquals(expectedBodyFields); + } + + Future 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), + }); + }); + }); +}