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

Support general chat #1297

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class InitialSnapshot {

final Uri? serverEmojiDataUrl; // TODO(server-6)

final String? realmEmptyTopicDisplayName; // TODO(server-10)

@JsonKey(readValue: _readUsersIsActiveFallbackTrue)
final List<User> realmUsers;
@JsonKey(readValue: _readUsersIsActiveFallbackFalse)
Expand Down Expand Up @@ -132,6 +134,7 @@ class InitialSnapshot {
required this.realmDefaultExternalAccounts,
required this.maxFileUploadSizeMib,
required this.serverEmojiDataUrl,
required this.realmEmptyTopicDisplayName,
required this.realmUsers,
required this.realmNonActiveUsers,
required this.crossRealmBots,
Expand Down
3 changes: 3 additions & 0 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.

2 changes: 1 addition & 1 deletion lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ extension type const TopicName(String _value) {
/// 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;
String? get displayName => _value.isEmpty ? null : _value;

/// The key to use for "same topic as" comparisons.
String canonicalize() => apiName.toLowerCase();
Expand Down
5 changes: 4 additions & 1 deletion lib/api/route/channels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ part 'channels.g.dart';
Future<GetStreamTopicsResult> getStreamTopics(ApiConnection connection, {
required int streamId,
}) {
return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {});
final supportsEmptyTopics = connection.zulipFeatureLevel! >= 334; // TODO(server-10)
return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {
if (supportsEmptyTopics) 'allow_empty_topic_name': true,
});
}

@JsonSerializable(fieldRename: FieldRename.snake)
Expand Down
1 change: 1 addition & 0 deletions lib/api/route/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
'user_avatar_url_field_optional': false, // TODO(#254): turn on
'stream_typing_notifications': true,
'user_settings_object': true,
'empty_topic_name': true,
},
});
}
Expand Down
4 changes: 4 additions & 0 deletions lib/api/route/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ Future<GetMessageResult> getMessage(ApiConnection connection, {
bool? applyMarkdown,
}) {
assert(connection.zulipFeatureLevel! >= 120);
final supportsEmptyTopics = connection.zulipFeatureLevel! >= 334; // TODO(server-10)
return connection.get('getMessage', GetMessageResult.fromJson, 'messages/$messageId', {
if (applyMarkdown != null) 'apply_markdown': applyMarkdown,
if (supportsEmptyTopics) 'allow_empty_topic_name': true,
});
}

Expand Down Expand Up @@ -90,6 +92,7 @@ Future<GetMessagesResult> getMessages(ApiConnection connection, {
bool? applyMarkdown,
// bool? useFirstUnreadAnchor // omitted because deprecated
}) {
final supportsEmptyTopics = connection.zulipFeatureLevel! >= 334; // TODO(server-10)
return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', {
'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!),
'anchor': RawParameter(anchor.toJson()),
Expand All @@ -98,6 +101,7 @@ Future<GetMessagesResult> getMessages(ApiConnection connection, {
'num_after': numAfter,
if (clientGravatar != null) 'client_gravatar': clientGravatar,
if (applyMarkdown != null) 'apply_markdown': applyMarkdown,
if (supportsEmptyTopics) 'allow_empty_topic_name': true,
});
}

Expand Down
11 changes: 8 additions & 3 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
}

TopicAutocompleteResult? _testTopic(TopicAutocompleteQuery query, TopicName topic) {
if (query.testTopic(topic)) {
if (query.testTopic(store, topic)) {
return TopicAutocompleteResult(topic: topic);
}
return null;
Expand All @@ -862,10 +862,15 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
class TopicAutocompleteQuery extends AutocompleteQuery {
TopicAutocompleteQuery(super.raw);

bool testTopic(TopicName topic) {
bool testTopic(PerAccountStore store, TopicName topic) {
// TODO(#881): Sort by match relevance, like web does.

if (topic.displayName == null) {
return store.realmEmptyTopicDisplayName.toLowerCase()
.contains(raw.toLowerCase());
}
return topic.displayName != raw
&& topic.displayName.toLowerCase().contains(raw.toLowerCase());
&& topic.displayName!.toLowerCase().contains(raw.toLowerCase());
}

@override
Expand Down
8 changes: 8 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
realmMandatoryTopics: initialSnapshot.realmMandatoryTopics,
realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold,
maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib,
realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName,
realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts,
customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields),
emailAddressVisibility: initialSnapshot.emailAddressVisibility,
Expand Down Expand Up @@ -315,6 +316,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
required this.realmMandatoryTopics,
required this.realmWaitingPeriodThreshold,
required this.maxFileUploadSizeMib,
required String? realmEmptyTopicDisplayName,
required this.realmDefaultExternalAccounts,
required this.customProfileFields,
required this.emailAddressVisibility,
Expand All @@ -335,6 +337,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
assert(realmUrl == connection.realmUrl),
assert(emoji.realmUrl == realmUrl),
_globalStore = globalStore,
_realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
_emoji = emoji,
_channels = channels,
_messages = messages;
Expand Down Expand Up @@ -381,6 +384,11 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
/// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold].
final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting
final int maxFileUploadSizeMib; // No event for this.
final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting
String get realmEmptyTopicDisplayName {
assert(_realmEmptyTopicDisplayName != null); // TODO(log)
return _realmEmptyTopicDisplayName ?? 'general chat';
}
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
List<CustomProfileField> customProfileFields;
/// For docs, please see [InitialSnapshot.emailAddressVisibility].
Expand Down
11 changes: 10 additions & 1 deletion lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,21 @@ class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicA

@override
Widget buildItem(BuildContext context, int index, TopicAutocompleteResult option) {
final Widget child;
if (option.topic.displayName == null) {
final store = PerAccountStoreWidget.of(context);
child = Text(store.realmEmptyTopicDisplayName,
style: const TextStyle(fontStyle: FontStyle.italic));
} else {
child = Text(option.topic.displayName!);
}

return InkWell(
onTap: () {
_onTapOption(context, option);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(option.topic.displayName)));
child: child));
}
}
42 changes: 33 additions & 9 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,20 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
@override
String _computeTextNormalized() {
String trimmed = text.trim();
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
// TODO(server-10): simplify
if (store.connection.zulipFeatureLevel! < 334) {
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
}

return trimmed;
}

@override
List<TopicValidationError> _computeValidationErrors() {
return [
if (mandatory && textNormalized == kNoTopicTopic)
if (mandatory && (textNormalized.isEmpty
|| textNormalized == kNoTopicTopic
|| textNormalized == store.realmEmptyTopicDisplayName))
TopicValidationError.mandatoryButEmpty,

if (
Expand All @@ -122,7 +129,7 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
}

void setTopic(TopicName newTopic) {
value = TextEditingValue(text: newTopic.displayName);
value = TextEditingValue(text: newTopic.displayName ?? '');
}
}

Expand Down Expand Up @@ -428,6 +435,16 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
@override
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
TextStyle hintStyle = TextStyle(
color: designVariables.textInput.withFadedAlpha(0.5));

if (widget.destination.destination
case StreamDestination(topic: TopicName(displayName: null))) {
// TODO(#1285): This applies to the entire hint text; ideally we'd only
// want to italize the "general chat" text, but this requires
// special l10n support for the hint text string.
hintStyle = hintStyle.copyWith(fontStyle: FontStyle.italic);
}

return ComposeAutocomplete(
narrow: widget.narrow,
Expand Down Expand Up @@ -469,8 +486,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
// this and offering two lines of touchable area.
contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding),
hintText: widget.hintText,
hintStyle: TextStyle(
color: designVariables.textInput.withFadedAlpha(0.5))))))));
hintStyle: hintStyle))))));
}
}

Expand Down Expand Up @@ -522,11 +538,13 @@ class _StreamContentInputState extends State<_StreamContentInput> {
final zulipLocalizations = ZulipLocalizations.of(context);
final streamName = store.streams[widget.narrow.streamId]?.name
?? zulipLocalizations.composeBoxUnknownChannelName;
final topic = TopicName(_topicTextNormalized);
return _ContentInput(
narrow: widget.narrow,
destination: TopicNarrow(widget.narrow.streamId, TopicName(_topicTextNormalized)),
destination: TopicNarrow(widget.narrow.streamId, topic),
controller: widget.controller,
hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized));
hintText: zulipLocalizations.composeBoxChannelContentHint(
streamName, topic.displayName ?? store.realmEmptyTopicDisplayName));
}
}

Expand All @@ -545,6 +563,9 @@ class _TopicInput extends StatelessWidget {
height: 22 / 20,
color: designVariables.textInput.withFadedAlpha(0.9),
).merge(weightVariableTextStyle(context, wght: 600));
final store = PerAccountStoreWidget.of(context);
final allowsEmptyTopics =
store.connection.zulipFeatureLevel! >= 334 && !store.realmMandatoryTopics;

return TopicAutocomplete(
streamId: streamId,
Expand All @@ -562,8 +583,11 @@ class _TopicInput extends StatelessWidget {
textInputAction: TextInputAction.next,
style: topicTextStyle,
decoration: InputDecoration(
hintText: zulipLocalizations.composeBoxTopicHintText,
hintText: (allowsEmptyTopics)
? store.realmEmptyTopicDisplayName
: zulipLocalizations.composeBoxTopicHintText,
hintStyle: topicTextStyle.copyWith(
fontStyle: (allowsEmptyTopics) ? FontStyle.italic : null,
color: designVariables.textInput.withFadedAlpha(0.5))))));
}
}
Expand All @@ -585,7 +609,7 @@ class _FixedDestinationContentInput extends StatelessWidget {
final streamName = store.streams[streamId]?.name
?? zulipLocalizations.composeBoxUnknownChannelName;
return zulipLocalizations.composeBoxChannelContentHint(
streamName, topic.displayName);
streamName, topic.displayName ?? store.realmEmptyTopicDisplayName);

case DmNarrow(otherRecipientIds: []): // The self-1:1 thread.
return zulipLocalizations.composeBoxSelfDmContentHint;
Expand Down
3 changes: 2 additions & 1 deletion lib/widgets/inbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -526,13 +526,14 @@ class _TopicItem extends StatelessWidget {
child: Text(
style: TextStyle(
fontSize: 17,
fontStyle: (topic.displayName == null) ? FontStyle.italic : null,
height: (20 / 17),
// TODO(design) check if this is the right variable
color: designVariables.labelMenuButton,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
topic.displayName))),
topic.displayName ?? store.realmEmptyTopicDisplayName))),
const SizedBox(width: 12),
if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign),
// TODO(design) copies the "@" marker color; is there a better color?
Expand Down
9 changes: 6 additions & 3 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,9 @@ class MessageListAppBarTitle extends StatelessWidget {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: Text(topic.displayName, style: const TextStyle(
Flexible(child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, style: TextStyle(
fontSize: 13,
fontStyle: (topic.displayName == null) ? FontStyle.italic : null,
).merge(weightVariableTextStyle(context)))),
if (icon != null)
Padding(
Expand Down Expand Up @@ -1094,11 +1095,13 @@ class StreamMessageRecipientHeader extends StatelessWidget {
child: Row(
children: [
Flexible(
child: Text(topic.displayName,
child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName,
// TODO: Give a way to see the whole topic (maybe a
// long-press interaction?)
overflow: TextOverflow.ellipsis,
style: recipientHeaderTextStyle(context))),
style: recipientHeaderTextStyle(context).copyWith(
fontStyle: (topic.displayName == null) ? FontStyle.italic : null,
))),
const SizedBox(width: 4),
// TODO(design) copies the recipient header in web; is there a better color?
Icon(size: 14, color: designVariables.colorMessageHeaderIconInteractive,
Expand Down
2 changes: 1 addition & 1 deletion test/api/model/model_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ extension MessageChecks on Subject<Message> {

extension TopicNameChecks on Subject<TopicName> {
Subject<String> get apiName => has((x) => x.apiName, 'apiName');
Subject<String> get displayName => has((x) => x.displayName, 'displayName');
Subject<String?> get displayName => has((x) => x.displayName, 'displayName');
}

extension StreamMessageChecks on Subject<StreamMessage> {
Expand Down
24 changes: 24 additions & 0 deletions test/api/route/channels_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ import '../../stdlib_checks.dart';
import '../fake_api.dart';

void main() {
test('smoke getStreamTopics', () {
return FakeApiConnection.with_((connection) async {
connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
await getStreamTopics(connection, streamId: 1);
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('GET')
..url.path.equals('/api/v1/users/me/1/topics')
..url.queryParameters.deepEquals({
'allow_empty_topic_name': 'true',
});
});
});

test('legacy: getStreamTopics when FL < 334', () {
return FakeApiConnection.with_(zulipFeatureLevel: 333, (connection) async {
connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
await getStreamTopics(connection, streamId: 1);
check(connection.takeRequests()).single.isA<http.Request>()
..method.equals('GET')
..url.path.equals('/api/v1/users/me/1/topics')
..url.queryParameters.deepEquals({});
});
});

test('smoke updateUserTopic', () {
return FakeApiConnection.with_((connection) async {
connection.prepare(json: {});
Expand Down
Loading