Skip to content

Follow user's time format setting (12 or 24 hour) #1730

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 14 commits into from
Jul 25, 2025
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
1 change: 1 addition & 0 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ class UserSettingsUpdateEvent extends Event {
final value = json['value'];
switch (UserSettingName.fromRawString(json['property'] as String)) {
case UserSettingName.twentyFourHourTime:
return TwentyFourHourTimeMode.fromApiValue(value as bool?);
case UserSettingName.displayEmojiReactionUsers:
return value as bool;
case UserSettingName.emojiset:
Expand Down
7 changes: 6 additions & 1 deletion lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,12 @@ class RecentDmConversation {
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true)
class UserSettings {
bool twentyFourHourTime;
@JsonKey(
fromJson: TwentyFourHourTimeMode.fromApiValue,
toJson: TwentyFourHourTimeMode.staticToJson,
)
TwentyFourHourTimeMode twentyFourHourTime;

bool? displayEmojiReactionUsers; // TODO(server-6)
Emojiset emojiset;
bool presenceEnabled;
Expand Down
8 changes: 6 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.

29 changes: 29 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,35 @@ enum UserSettingName {
String toJson() => _$UserSettingNameEnumMap[this]!;
}

/// A value from [UserSettings.twentyFourHourTime].
enum TwentyFourHourTimeMode {
twelveHour(apiValue: false),
twentyFourHour(apiValue: true),

/// The locale's default format (12-hour for en_US, 24-hour for fr_FR, etc.).
// TODO(#1727) actually follow this
// Not sent by current servers, but planned when most client installs accept it:
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696
// TODO(server-future) Write down what server N starts sending null;
// adjust the comment; leave a TODO(server-N) to delete the comment
localeDefault(apiValue: null),
;

const TwentyFourHourTimeMode({required this.apiValue});

final bool? apiValue;

static bool? staticToJson(TwentyFourHourTimeMode instance) => instance.apiValue;

bool? toJson() => TwentyFourHourTimeMode.staticToJson(this);

static TwentyFourHourTimeMode fromApiValue(bool? value) => switch (value) {
false => twelveHour,
true => twentyFourHour,
null => localeDefault,
};
}

/// As in [UserSettings.emojiset].
@JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
enum Emojiset {
Expand Down
20 changes: 14 additions & 6 deletions lib/api/route/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ Future<void> updateSettings(ApiConnection connection, {
for (final entry in newSettings.entries) {
final name = entry.key;
final valueRaw = entry.value;
final value = switch (name) {
UserSettingName.twentyFourHourTime => valueRaw as bool,
UserSettingName.displayEmojiReactionUsers => valueRaw as bool,
UserSettingName.emojiset => RawParameter((valueRaw as Emojiset).toJson()),
UserSettingName.presenceEnabled => valueRaw as bool,
};
final Object? value;
switch (name) {
case UserSettingName.twentyFourHourTime:
final mode = (valueRaw as TwentyFourHourTimeMode);
// TODO(server-future) allow localeDefault for servers that support it
assert(mode != TwentyFourHourTimeMode.localeDefault);
value = mode.toJson();
case UserSettingName.displayEmojiReactionUsers:
value = valueRaw as bool;
case UserSettingName.emojiset:
value = RawParameter((valueRaw as Emojiset).toJson());
case UserSettingName.presenceEnabled:
value = valueRaw as bool;
}
params[name.toJson()] = value;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ class PerAccountStore extends PerAccountStoreBase with
}
switch (event.property!) {
case UserSettingName.twentyFourHourTime:
userSettings.twentyFourHourTime = event.value as bool;
userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode;
case UserSettingName.displayEmojiReactionUsers:
userSettings.displayEmojiReactionUsers = event.value as bool;
case UserSettingName.emojiset:
Expand Down
17 changes: 15 additions & 2 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1304,13 +1304,26 @@ class GlobalTime extends StatelessWidget {
final GlobalTimeNode node;
final TextStyle ambientTextStyle;

static final _dateFormat = intl.DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date
static final _format12 =
intl.DateFormat('EEE, MMM d, y').addPattern('h:mm aa', ', ');
static final _format24 =
intl.DateFormat('EEE, MMM d, y').addPattern('Hm', ', ');
static final _formatLocaleDefault =
intl.DateFormat('EEE, MMM d, y').addPattern('jm', ', ');

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final twentyFourHourTimeMode = store.userSettings.twentyFourHourTime;
// Design taken from css for `.rendered_markdown & time` in web,
// see zulip:web/styles/rendered_markdown.css .
final text = _dateFormat.format(node.datetime.toLocal());
// TODO(i18n): localize; see plan with ffi in #45
final format = switch (twentyFourHourTimeMode) {
TwentyFourHourTimeMode.twelveHour => _format12,
TwentyFourHourTimeMode.twentyFourHour => _format24,
TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault,
};
final text = format.format(node.datetime.toLocal());
final contentTheme = ContentTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
Expand Down
13 changes: 7 additions & 6 deletions lib/widgets/lightbox.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:video_player/video_player.dart';

import '../api/core.dart';
Expand All @@ -12,6 +11,7 @@ import '../model/binding.dart';
import 'actions.dart';
import 'content.dart';
import 'dialog.dart';
import 'message_list.dart';
import 'page.dart';
import 'store.dart';
import 'user.dart';
Expand Down Expand Up @@ -167,6 +167,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
final store = PerAccountStoreWidget.of(context);
final themeData = Theme.of(context);

Expand All @@ -176,11 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {

PreferredSizeWidget? appBar;
if (_headerFooterVisible) {
// TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
final timestampText = DateFormat
.yMMMd(/* TODO(#278): Pass selected language here, I think? */)
.add_Hms()
.format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));
final timestampText = MessageTimestampStyle.lightbox
.format(widget.message.timestamp,
now: DateTime.now(),
twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
zulipLocalizations: zulipLocalizations);

// We use plain [AppBar] instead of [ZulipAppBar], even though this page
// has a [PerAccountStore], because:
Expand Down
139 changes: 92 additions & 47 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:intl/intl.dart' hide TextDirection;

import '../api/model/model.dart';
import '../generated/l10n/zulip_localizations.dart';
import '../model/binding.dart';
import '../model/database.dart';
import '../model/message.dart';
import '../model/message_list.dart';
Expand Down Expand Up @@ -1840,8 +1841,14 @@ class DateText extends StatelessWidget {

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final messageListTheme = MessageListTheme.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format(
timestamp,
now: ZulipBinding.instance.utcNow().toLocal(),
Copy link
Member

Choose a reason for hiding this comment

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

Using the binding is a good idea. How about having the callee invoke it? Then it doesn't need to be a parameter that each caller passes.

Copy link
Member

Choose a reason for hiding this comment

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

(Potentially that happens after the other commits — it might be simpler to do that change after this one:
26d5ef5 msglist [nfc]: Finish centralizing on MessageTimestampStyle

than vice versa.)

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Jul 23, 2025

Choose a reason for hiding this comment

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

I was thinking we'd want format to be a pure function, so not a right place to check the clock. And I guess that callers would be more likely to take care of refreshing relative times (e.g. #293, #891) if they're responsible for reading the clock. What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, refreshing relative times is an interesting point. I guess let's leave it this way for now, and we'll see what we come up with to organize the code when we go to handle that.

twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
zulipLocalizations: zulipLocalizations)!;
return Text(
style: TextStyle(
color: messageListTheme.labelTime,
Expand All @@ -1851,46 +1858,7 @@ class DateText extends StatelessWidget {
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps
fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')],
),
formatHeaderDate(
zulipLocalizations,
DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
now: DateTime.now()));
}
}

@visibleForTesting
String formatHeaderDate(
ZulipLocalizations zulipLocalizations,
DateTime dateTime, {
required DateTime now,
}) {
assert(!dateTime.isUtc && !now.isUtc,
'`dateTime` and `now` need to be in local time.');

if (dateTime.year == now.year &&
dateTime.month == now.month &&
dateTime.day == now.day) {
return zulipLocalizations.today;
}

final yesterday = now
.copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0)
.add(const Duration(days: -1));
if (dateTime.year == yesterday.year &&
dateTime.month == yesterday.month &&
dateTime.day == yesterday.day) {
return zulipLocalizations.yesterday;
}

// If it is Dec 1 and you see a label that says `Dec 2`
// it could be misinterpreted as Dec 2 of the previous
// year. For times in the future, those still on the
// current day will show as today (handled above) and
// any dates beyond that show up with the year.
if (dateTime.year == now.year && dateTime.isBefore(now)) {
return DateFormat.MMMd().format(dateTime);
} else {
return DateFormat.yMMMd().format(dateTime);
formattedTimestamp);
}
}

Expand All @@ -1915,12 +1883,17 @@ class SenderRow extends StatelessWidget {

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
final store = PerAccountStoreWidget.of(context);
final messageListTheme = MessageListTheme.of(context);
final designVariables = DesignVariables.of(context);

final sender = store.getUser(message.senderId);
final timestamp = timestampStyle.format(message.timestamp);
final timestamp = timestampStyle
.format(message.timestamp,
now: DateTime.now(),
twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
zulipLocalizations: zulipLocalizations);

final showAsMuted = _showAsMuted(context, store);

Expand Down Expand Up @@ -1982,11 +1955,14 @@ class SenderRow extends StatelessWidget {
}
}

// TODO centralize on this for wherever we show message timestamps
enum MessageTimestampStyle {
none,
dateOnlyRelative,
timeOnly,

// TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45
lightbox,

/// The longest format, with full date and time as numbers, not "Today"/etc.
///
/// For UI contexts focused just on the one message,
Expand All @@ -2001,19 +1977,88 @@ enum MessageTimestampStyle {
full,
;

static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US');
static final _fullFormat = DateFormat.yMMMd().add_jm();
static String _formatDateOnlyRelative(
DateTime dateTime, {
required DateTime now,
required ZulipLocalizations zulipLocalizations,
}) {
assert(!dateTime.isUtc && !now.isUtc,
'`dateTime` and `now` need to be in local time.');

if (dateTime.year == now.year &&
dateTime.month == now.month &&
dateTime.day == now.day) {
return zulipLocalizations.today;
}

final yesterday = now
.copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0)
.add(const Duration(days: -1));
if (dateTime.year == yesterday.year &&
dateTime.month == yesterday.month &&
dateTime.day == yesterday.day) {
return zulipLocalizations.yesterday;
}

// If it is Dec 1 and you see a label that says `Dec 2`
// it could be misinterpreted as Dec 2 of the previous
// year. For times in the future, those still on the
// current day will show as today (handled above) and
// any dates beyond that show up with the year.
if (dateTime.year == now.year && dateTime.isBefore(now)) {
return DateFormat.MMMd().format(dateTime);
} else {
return DateFormat.yMMMd().format(dateTime);
}
}

static final _timeFormat12 = DateFormat('h:mm aa');
static final _timeFormat24 = DateFormat('Hm');
static final _timeFormatLocaleDefault = DateFormat('jm');
static final _timeFormat12WithSeconds = DateFormat('h:mm:ss aa');
static final _timeFormat24WithSeconds = DateFormat('Hms');
static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms');

static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) {
TwentyFourHourTimeMode.twelveHour => _timeFormat12,
TwentyFourHourTimeMode.twentyFourHour => _timeFormat24,
TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault,
};

static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) {
TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds,
TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds,
TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds,
};

/// Format a [Message.timestamp] for this mode.
// TODO(i18n): locale-specific formatting (see #45 for a plan with ffi)
String? format(int messageTimestamp) {
String? format(
int messageTimestamp, {
required DateTime now,
required ZulipLocalizations zulipLocalizations,
required TwentyFourHourTimeMode twentyFourHourTimeMode,
}) {
final asDateTime =
DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp);

switch (this) {
case none: return null;
case timeOnly: return _timeOnlyFormat.format(asDateTime);
case full: return _fullFormat.format(asDateTime);
case dateOnlyRelative:
return _formatDateOnlyRelative(asDateTime,
now: now, zulipLocalizations: zulipLocalizations);
case timeOnly:
return _resolveTimeFormat(twentyFourHourTimeMode).format(asDateTime);
case lightbox:
return DateFormat
.yMMMd()
.addPattern(_resolveTimeFormatWithSeconds(twentyFourHourTimeMode).pattern)
.format(asDateTime);
case full:
return DateFormat
.yMMMd()
.addPattern(_resolveTimeFormat(twentyFourHourTimeMode).pattern)
.format(asDateTime);
}
}
}
Expand Down
Loading