From 619e5be8d879284381d31583a6dd73d36934ee20 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 2 Aug 2025 22:16:13 +0430 Subject: [PATCH 1/6] api: Add InitialSnapshot.realmEnableReadReceipts --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 2 ++ test/example_data.dart | 2 ++ 3 files changed, 7 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 342128b8b7..0f22f0b5f0 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -90,6 +90,8 @@ class InitialSnapshot { final bool realmAllowMessageEditing; final int? realmMessageContentEditLimitSeconds; + final bool realmEnableReadReceipts; + final bool realmPresenceDisabled; final Map realmDefaultExternalAccounts; @@ -158,6 +160,7 @@ class InitialSnapshot { required this.realmWaitingPeriodThreshold, required this.realmAllowMessageEditing, required this.realmMessageContentEditLimitSeconds, + required this.realmEnableReadReceipts, required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index ff05e50c20..e7a5923c89 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -91,6 +91,7 @@ InitialSnapshot _$InitialSnapshotFromJson( realmAllowMessageEditing: json['realm_allow_message_editing'] as bool, realmMessageContentEditLimitSeconds: (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), + realmEnableReadReceipts: json['realm_enable_read_receipts'] as bool, realmPresenceDisabled: json['realm_presence_disabled'] as bool, realmDefaultExternalAccounts: (json['realm_default_external_accounts'] as Map).map( @@ -162,6 +163,7 @@ Map _$InitialSnapshotToJson( 'realm_allow_message_editing': instance.realmAllowMessageEditing, 'realm_message_content_edit_limit_seconds': instance.realmMessageContentEditLimitSeconds, + 'realm_enable_read_receipts': instance.realmEnableReadReceipts, 'realm_presence_disabled': instance.realmPresenceDisabled, 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, diff --git a/test/example_data.dart b/test/example_data.dart index 267d75d8ed..8761465db9 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1224,6 +1224,7 @@ InitialSnapshot initialSnapshot({ int? realmWaitingPeriodThreshold, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool? realmEnableReadReceipts, bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, @@ -1271,6 +1272,7 @@ InitialSnapshot initialSnapshot({ realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmAllowMessageEditing: realmAllowMessageEditing ?? true, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + realmEnableReadReceipts: realmEnableReadReceipts ?? true, realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, From 833b9ca2a573bc712e38762f71ff7c76717080d4 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 2 Aug 2025 22:27:10 +0430 Subject: [PATCH 2/6] realm: Add RealmStore.realmEnableReadReceipts --- lib/model/realm.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index c214b554fa..d54e5a5467 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -42,6 +42,7 @@ mixin RealmStore on PerAccountStoreBase { realmMessageContentEditLimitSeconds == null ? null : Duration(seconds: realmMessageContentEditLimitSeconds!); int? get realmMessageContentEditLimitSeconds; + bool get realmEnableReadReceipts; bool get realmPresenceDisabled; int get realmWaitingPeriodThreshold; @@ -141,6 +142,8 @@ mixin ProxyRealmStore on RealmStore { @override int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; @override + bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts; + @override bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; @override int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; @@ -180,6 +183,7 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, + realmEnableReadReceipts = initialSnapshot.realmEnableReadReceipts, realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, @@ -208,6 +212,8 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore { @override final int? realmMessageContentEditLimitSeconds; @override + final bool realmEnableReadReceipts; + @override final bool realmPresenceDisabled; @override final int realmWaitingPeriodThreshold; From 8f2e19936b07c748749dc4dbc8a2296a774ae04d Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:29:39 +0430 Subject: [PATCH 3/6] icons: Add `check_check` icon, from Web Figma file Web Figma link: https://www.figma.com/design/jbNOiBWvbtLuHaiTj4CW0G/Zulip-Web-App?node-id=7352-690&t=OXW354YOc17R7B6G-0 This icon is different from the icon in the mobile Figma design. Alya suggested to use the web version and maybe make some adjustments to it. Mobile Figma link: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=501-19868&t=bRbH05njayjM74v3-0 Related CZO discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/read-receipts.20icon.20in.20.23F1706/near/2234758 --- assets/icons/ZulipIcons.ttf | Bin 16968 -> 17160 bytes assets/icons/check_check.svg | 4 ++ lib/widgets/icons.dart | 95 ++++++++++++++++++----------------- 3 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 assets/icons/check_check.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index b4cca7838c3b28c6f1cf438bd7b7db720a5810de..1285c9ba7f7625567a47d8f337d03232393c94de 100644 GIT binary patch delta 2275 zcmb7FTWnKx82|pKr>B>-T{pU}3>~B6qH}I+;?|z^v}ax0t=+ndcu9x>>ev`zH(}uL zrAsu#7()VmkQic&SqM)iGJ=SE*aI=V$&0uUg2bpHh7j-tUqpZ3*?*K5AMDBReE;*^ zZ>Q(_hvK7?qDX>>DrkXhw6k~j8_8pj<{lG?2$9`Aw7a$a%^R;b5NRoB?H`>g%sxK% z^#LNsJ;-kzoIEjpyG~mn@=Xx6&QFXL4jljO+C}Kz1v)0ca0+z<=Vf5)#MIpUt#AMN z8~zp%BQ-fQY83MCzjcYodkqSUQ-%3iaY%GP7{YVIbYW_&=8D-)WZWfEI%j8&&YfBw z?<6uIS00{TZuE{=6`+a__WyA9^!+w5QmPjRi=R^^ZKVseDm1ZE>=hTq1EpF?DGyb* z8dRs%1+tTeS|~$%X^s@~Qxum@T@<4Pnbb{5oTAi2DN5sPLzBuHWxgH!B(4bcLN5lp z9=7Mcx}PyHEZ1>T$zeIpSdQZetWlU=ATL5j$go11aAiWChh~Oegoa63*bji($Vqi5 zY%^$t;z+I^GzCcmeD{Epqb)cUbvVgc>LCOOAnYKNVsO)qs~Rc>)RVLUIxOKt8YqaK z=tw&Yr(FLK-0L`*R0Fw=$`ZJD!P<=^bn2s5V3CG}X?f4%9!1i65t(#AsS8RmR3h2n zzG^wjBX}u5W*Q$5m+!!28JlAEB#DRG_8;T%U`ordXo*u+teZlxch z5W2!c?5AQLVGS77LY;H&gDKZoPa83@2txC;*m(qU4qr#Er~LKtxX`&>X#9RULJRZ> zoux17C2ZDOwO#PR<#F{qf9{ik^sx~|FrpBxtphKVL9Gaow)i}V9++n6Wn^r^Fo|cL zW`>%OB)f~kVHyi+e`>SS>Vmai)QrkaSQylX6BiLh*bwYzk>+_SuFxv|L4OHV_#p_w z39nTSfqM~(opVw#v?^`|T9a~`Zq)o=+MH5Z(}%U?=K=WB;pdt6rHHlQ#aIu@En44U zuauO{*53{nQZ3t*gZa@V4zTu`@mt|Qmm=`U8xnz(yR;Uo(i*tj|0*5P9)r7Px@5VDr!tlZa=%;Ns^~0-a#ppzoIAxn9 zi&rjyo#vTZF9w7=V32@31A|JQT***FHK#CmwZ{+|I_f7y|U6Dgz00qvkZGpF{X8 z65-AaS!pE^d;l-&Fb$UWI;ZUThZS0B#8ub?`@UYo>`tX&m&nHT{7^KL>>ubF9)x_M zwBT?QJ)D*82H)`;2KGtBKnoK1q|iGOeW0Tfy`Tpq4A3zN6Legn8}y(=3KX9k3u(|p zMZN(&z+s6T=%hpfbV?!%IxP_gosrN%XC-(F?@A1T9+8NG9+k*|&PfR%wwzj_Z-#EMd$CXjO#P^DfgchZ&%#(ba<|M{ob7Syf5s#Tv=Tiul&^?@b9Ur ztIAa0*)YE0Y9JT*DsaE%a!?5#ul3a~*S0O=_P?&Bi0?bKxN2*{w`3c(rK3f0h#f7$ zrvx{PV0)*Cixx!#YkOD=SR7luM}!(ZPqJ5>!C;l zlX<^*8bbn#-?%i*UmJA(==F*wXKk>`uDPE4yv4II>@_`DI%VlW^{37F;GaxPx)LlP3cj{0eb@vOA0Q%eS%+3@Kp8WahC3J5?!C4NxhBd|dGSo9WUs}5P z)jxlOcLpEHxnl?7#qq{rjQ!_OxG-N_S}}H` z%~jW`>#nuOIwW<{DBER3_DV@iX_hc|M4~bvgOZS>q*#SzNQNcN+C`DpG}g}RI8Skh zKuTkg!~%P*tM5L8m<=5lOFTAk#s-e#*ezS+3Gw4HBr)4%0;mM?0-71wibg_m*pI_pNc)ABUo*KBs!Rj|iPo+9%E7BT5#rAdUTUOT!+<*Zzk@;9;~KguuimvgK`HHzC2 z94WMhG(e%^wwtO{gVc<*@c*Z+0j+8J8Cu!0^Ml-HWaHAtxGpoHD zP1HhxsxOnyzK2?bu+%vSA=O$`F#}FpHOyuzpffbiJ7;0xVpmSF^wFy{GrYy_SO&oA z2x%2+K$>$mAtjKm$&>6=@+c@;<~-AG@zf5hlOS&51XexKxoVZic-|ZHKIYh>$az2w z+-rYopOxw-B|{Kd8=@eV`B^3BP8h+;8F#MIW^S$o40%C0N%2o#|S6MOr>f0DBg2~taX|omJF%nXIu3+wDiERh^eV>OtIM+y_+Oe#MXog7F0M&-PxuH2|GxvuJyYZBV$&;Y#X zPzXNlP!@i`p%FNrZ<}K98HW<^Lk=b3haDP*&pMQbA1Uht8iHPOXcRu@&>(!?p&b0E zLvi>qha&I=hYIlH4o$!p9pb+vCmhPaOAf_TEM9gf4?pS982lB7_>0PtLt3Fz4vjBw zZ#Zje3IVb$$tpjmYi7`V(fq)??do++xfWeFtkc$?b<=fM+-dhm?iKH}_hP+O|AMdJ z`@EsE;atO=#-+yZ{9gY-|Ls62uoAf2bfM`+b4T;7b=LY1TY6fSTD`4vt)I4)+OD+s sw_o4z>H~N8gGc{45m}xMd~Gd%-11&8|I}#meC3Da#BwlogJG0^0ToL#F#rGn diff --git a/assets/icons/check_check.svg b/assets/icons/check_check.svg new file mode 100644 index 0000000000..3d7b4a59d6 --- /dev/null +++ b/assets/icons/check_check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index a8180cd9ad..887c115cee 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -48,143 +48,146 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check". static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_check". + static const IconData check_check = IconData(0xf109, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_circle_checked". - static const IconData check_circle_checked = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData check_circle_checked = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "check_circle_unchecked". - static const IconData check_circle_unchecked = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData check_circle_unchecked = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "check_remove". - static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData check_remove = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_down". - static const IconData chevron_down = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData chevron_down = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "link". - static const IconData link = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData link = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "see_who_reacted". - static const IconData see_who_reacted = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData see_who_reacted = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf135, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf135, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf136, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf136, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf137, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From 280e3ee68465768b16942acbf9a07494ff00506e Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 15 Jul 2025 14:05:36 +0430 Subject: [PATCH 4/6] deps: Add styled_text --- pubspec.lock | 16 ++++++++++++++++ pubspec.yaml | 1 + 2 files changed, 17 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index d9ad3f0ae9..e179994e64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1059,6 +1059,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + styled_text: + dependency: "direct main" + description: + name: styled_text + sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3 + url: "https://pub.dev" + source: hosted + version: "8.1.0" sync_http: dependency: transitive description: @@ -1339,6 +1347,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + xmlstream: + dependency: transitive + description: + name: xmlstream + sha256: cfc14e3f256997897df9481ae630d94c2d85ada5187ebeb868bb1aabc2c977b4 + url: "https://pub.dev" + source: hosted + version: "1.1.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 397342ca76..0a3abfedff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: share_plus_platform_interface: ^6.0.0 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 + styled_text: ^8.1.0 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" video_player: ^2.10.0 From dce761cbc24c17bf8d75ad0ccf064ed4cf32db61 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 30 Jul 2025 22:56:02 +0430 Subject: [PATCH 5/6] api: Add route getReadReceipts --- lib/api/route/messages.dart | 20 ++++++++++++++++++++ lib/api/route/messages.g.dart | 12 ++++++++++++ test/api/route/messages_test.dart | 11 +++++++++++ 3 files changed, 43 insertions(+) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 44053bcc4a..428a7c9a95 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -436,3 +436,23 @@ class UpdateMessageFlagsForNarrowResult { Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this); } + +/// https://zulip.com/api/get-read-receipts +Future getReadReceipts(ApiConnection connection, { + required int messageId, +}) { + return connection.get('getReadReceipts', GetReadReceiptsResult.fromJson, + 'messages/$messageId/read_receipts', null); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GetReadReceiptsResult { + const GetReadReceiptsResult({required this.userIds}); + + final List userIds; + + factory GetReadReceiptsResult.fromJson(Map json) => + _$GetReadReceiptsResultFromJson(json); + + Map toJson() => _$GetReadReceiptsResultToJson(this); +} diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 306a58ca4d..4169634701 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -89,6 +89,18 @@ Map _$UpdateMessageFlagsForNarrowResultToJson( 'found_newest': instance.foundNewest, }; +GetReadReceiptsResult _$GetReadReceiptsResultFromJson( + Map json, +) => GetReadReceiptsResult( + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$GetReadReceiptsResultToJson( + GetReadReceiptsResult instance, +) => {'user_ids': instance.userIds}; + const _$AnchorCodeEnumMap = { AnchorCode.newest: 'newest', AnchorCode.oldest: 'oldest', diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index a862b512b6..24be32fba2 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -829,4 +829,15 @@ void main() { }); }); }); + + test('smoke getReadReceipts', () { + return FakeApiConnection.with_((connection) async { + final response = GetReadReceiptsResult(userIds: [7, 6543, 210]); + connection.prepare(json: response.toJson()); + await getReadReceipts(connection, messageId: 123321); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/123321/read_receipts'); + }); + }); } From 6ae29f9db2dc38e579cda89755fcace1cd99eda5 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 14 Jul 2025 14:35:23 +0430 Subject: [PATCH 6/6] action_sheet: Add 'View read receipts' button Fixes: #667 --- assets/l10n/app_en.arb | 23 ++ lib/generated/l10n/zulip_localizations.dart | 30 +++ .../l10n/zulip_localizations_ar.dart | 25 ++ .../l10n/zulip_localizations_de.dart | 25 ++ .../l10n/zulip_localizations_en.dart | 25 ++ .../l10n/zulip_localizations_fr.dart | 25 ++ .../l10n/zulip_localizations_it.dart | 25 ++ .../l10n/zulip_localizations_ja.dart | 25 ++ .../l10n/zulip_localizations_nb.dart | 25 ++ .../l10n/zulip_localizations_pl.dart | 25 ++ .../l10n/zulip_localizations_ru.dart | 25 ++ .../l10n/zulip_localizations_sk.dart | 25 ++ .../l10n/zulip_localizations_sl.dart | 25 ++ .../l10n/zulip_localizations_uk.dart | 25 ++ .../l10n/zulip_localizations_zh.dart | 25 ++ lib/widgets/action_sheet.dart | 55 ++++ lib/widgets/read_receipts.dart | 235 ++++++++++++++++++ lib/widgets/theme.dart | 7 + test/widgets/action_sheet_test.dart | 49 ++++ test/widgets/read_receipts_test.dart | 162 ++++++++++++ 20 files changed, 886 insertions(+) create mode 100644 lib/widgets/read_receipts.dart create mode 100644 test/widgets/read_receipts_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index fad1fdfc79..0718fa7082 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -171,6 +171,29 @@ "num": {"type": "int", "example": "2"} } }, + "actionSheetOptionViewReadReceipts": "View read receipts", + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "actionSheetReadReceipts": "Read receipts", + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "actionSheetReadReceiptsReadCount": "{count, plural, =1{This message has been read by {count} person:} other{This message has been read by {count} people:}}", + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": {"type": "int", "example": "1"} + } + }, + "actionSheetReadReceiptsZeroReadCount": "No one has read this message yet.", + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "actionSheetReadReceiptsErrorReadCount": "Failed to load read receipts.", + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6e3d6d8be2..4a6b8741d8 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -371,6 +371,36 @@ abstract class ZulipLocalizations { /// **'Votes for {emojiName} ({num})'** String seeWhoReactedSheetUserListLabel(String emojiName, int num); + /// Label for the 'View read receipts' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'View read receipts'** + String get actionSheetOptionViewReadReceipts; + + /// Title for the "Read receipts" bottom sheet. + /// + /// In en, this message translates to: + /// **'Read receipts'** + String get actionSheetReadReceipts; + + /// Label in the "Read receipts" bottom sheet when one or more people have read the message. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{This message has been read by {count} person:} other{This message has been read by {count} people:}}'** + String actionSheetReadReceiptsReadCount(int count); + + /// Label in the "Read receipts" bottom sheet when no one has read the message. + /// + /// In en, this message translates to: + /// **'No one has read this message yet.'** + String get actionSheetReadReceiptsZeroReadCount; + + /// Label in the "Read receipts" bottom sheet when loading read receipts failed. + /// + /// In en, this message translates to: + /// **'Failed to load read receipts.'** + String get actionSheetReadReceiptsErrorReadCount; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index cca05f41e2..e9c8522b98 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a5ffc40f1d..bcc0ee5f47 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -151,6 +151,31 @@ class ZulipLocalizationsDe extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 6191c23018..e73f03166e 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 5a922da313..ebcca35a94 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsFr extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index f330fd6f59..0cb6c301e1 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -150,6 +150,31 @@ class ZulipLocalizationsIt extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 0a37c8a874..a2b5138407 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -146,6 +146,31 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 66e62b13dd..20dc9e29a4 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index c675e00354..37cb7e2b20 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -151,6 +151,31 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 655a2a07db..4618363cbb 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -151,6 +151,31 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 205bab746b..a9cce1f0a6 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 6cc6780e46..4d7b1918a9 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -149,6 +149,31 @@ class ZulipLocalizationsSl extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 9e25589959..84802c7421 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -152,6 +152,31 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index db563a0434..7d5ea5428f 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -148,6 +148,31 @@ class ZulipLocalizationsZh extends ZulipLocalizations { return 'Votes for $emojiName ($num)'; } + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'This message has been read by $count people:', + one: 'This message has been read by $count person:', + ); + return '$_temp0'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 92ac06acd9..011e1a1a82 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -29,6 +29,7 @@ import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; import 'page.dart'; +import 'read_receipts.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -126,6 +127,41 @@ class BottomSheetHeaderPlainText extends StatelessWidget { } } + +/// A plain text widget for informational content in a bottom sheet. +/// +/// Use it to present short, non-interactive explanatory messages to the user, +/// such as an error message or other feedback. +/// +/// Comes with built-in 16px horizontal padding. +/// +/// Style-wise, this mostly follows the design of [BottomSheetHeaderPlainText], +/// as there is no design for this in Figma right now. +// TODO(design) create +class BottomSheetInfoText extends StatelessWidget { + const BottomSheetInfoText({super.key, required this.text, this.textAlign}); + + final String text; + final TextAlign? textAlign; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + child: Text( + textAlign: textAlign, + style: TextStyle( + color: designVariables.labelTime, + fontSize: 17, + height: 22 / 17), + text))); + } +} + /// A button in an action sheet. /// /// When built from server data, the action sheet ignores changes in that data; @@ -696,6 +732,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final reactions = message.reactions; final hasReactions = reactions != null && reactions.total > 0; + final readReceiptsEnabled = store.realmEnableReadReceipts; + // The UI that's conditioned on this won't live-update during this appearance // of the action sheet (we avoid calling composeBoxControllerOf in a build // method; see its doc). @@ -715,6 +753,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes ReactionButtons(message: message, pageContext: pageContext), if (hasReactions) ViewReactionsButton(message: message, pageContext: pageContext), + if (readReceiptsEnabled) + ViewReadReceiptsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -964,6 +1004,21 @@ class ViewReactionsButton extends MessageActionSheetMenuItemButton { } } +class ViewReadReceiptsButton extends MessageActionSheetMenuItemButton { + ViewReadReceiptsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.check_check; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionViewReadReceipts; + } + + @override void onPressed() { + showReadReceiptsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart new file mode 100644 index 0000000000..b87f1ebfe1 --- /dev/null +++ b/lib/widgets/read_receipts.dart @@ -0,0 +1,235 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:styled_text/styled_text.dart'; + +import '../api/route/messages.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import 'action_sheet.dart'; +import 'actions.dart'; +import 'color.dart'; +import 'inset_shadow.dart'; +import 'profile.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +/// Opens a bottom sheet showing who has read the message. +void showReadReceiptsSheet(BuildContext pageContext, {required int messageId}) { + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + + showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ReadReceipts(messageId: messageId))); + }); +} + +/// The read receipts sheet. +/// +/// Figma link: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20647&t=lSnHudU6l7NWx0Fa-0 +class ReadReceipts extends StatefulWidget { + const ReadReceipts({super.key, required this.messageId}); + + final int messageId; + + static const _helpCenterUrl = 'https://zulip.com/help/read-receipts'; + + @override + State createState() => _ReadReceiptsState(); +} + +class _ReadReceiptsState extends State with PerAccountStoreAwareStateMixin { + List userIds = []; + FetchStatus status = FetchStatus.loading; + + @override + void onNewStore() { + tryFetchReadReceipts(context); + } + + Future tryFetchReadReceipts(BuildContext context) async { + final store = PerAccountStoreWidget.of(context); + try { + final result = await getReadReceipts(store.connection, messageId: widget.messageId); + + if (!context.mounted) return; + final storeNow = PerAccountStoreWidget.of(context); + if (!identical(store, storeNow)) return; + + // TODO(i18n): add locale-aware sorting + userIds = result.userIds.sortedByCompare( + (id) => storeNow.userDisplayName(id), + (nameA, nameB) => nameA.toLowerCase().compareTo(nameB.toLowerCase()), + ); + status = FetchStatus.success; + } catch (e) { + status = FetchStatus.error; + } finally { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 500, // TODO(design) tune + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ReadReceiptsHeader(receiptCount: userIds.length, status: status), + Expanded(child: _ReadReceiptsUserList(userIds: userIds, status: status)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ])); + } +} + +enum FetchStatus { loading, success, error } + +class _ReadReceiptsHeader extends StatelessWidget { + const _ReadReceiptsHeader({required this.receiptCount, required this.status}); + + final int receiptCount; + final FetchStatus status; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Padding( + // In Figma design, this is `EdgeInsetsDirectional.fromSTEB(18, 16, 18, 8)`, + // which is different from [BottomSheetHeaderPlainText]'s padding. We + // ignore the Figma's version to make things consistent. + // + // Related GitHub comment: + // https://github.com/zulip/zulip-flutter/pull/1706#discussion_r2248861418 + padding: EdgeInsetsDirectional.fromSTEB(16, 16, 16, 4), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(zulipLocalizations.actionSheetReadReceipts, + style: TextStyle( + fontSize: 20, + height: 20 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600))), + if (status == FetchStatus.success && receiptCount > 0) + StyledText( + text: zulipLocalizations.actionSheetReadReceiptsReadCount(receiptCount), + tags: { + 'z-link': StyledTextActionTag((_, _) { + PlatformActions.launchUrl(context, Uri.parse(ReadReceipts._helpCenterUrl)); + }, + style: TextStyle( + decoration: TextDecoration.underline, + decorationStyle: TextDecorationStyle.solid, + // We use the default value for this, as there's no obvious + // way to map the thickness value from Figma design as it is + // a percentage of the font size. + decorationThickness: 1, + // decorationOffset: // TODO(upstream #30541) + color: designVariables.link, + decorationColor: designVariables.link), + )}, + style: TextStyle(fontSize: 17, height: 22 / 17, + color: designVariables.textMessage)), + ])); + } +} + +class _ReadReceiptsUserList extends StatelessWidget { + const _ReadReceiptsUserList({required this.userIds, required this.status}); + + final List userIds; + final FetchStatus status; + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Center( + child: switch(status) { + FetchStatus.loading => CircularProgressIndicator(), + FetchStatus.error => BottomSheetInfoText( + text: localizations.actionSheetReadReceiptsErrorReadCount, + textAlign: TextAlign.center), + FetchStatus.success => userIds.isEmpty + ? BottomSheetInfoText( + text: localizations.actionSheetReadReceiptsZeroReadCount, + textAlign: TextAlign.center) + : InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: userIds.length, + itemBuilder: (context, index) => + ReadReceiptsUserItem(userId: userIds[index]))) + }); + } +} + + +// TODO: deduplicate the code with [ViewReactionsUserItem] +@visibleForTesting +class ReadReceiptsUserItem extends StatelessWidget { + const ReadReceiptsUserItem({super.key, required this.userId}); + + final int userId; + + void _onPressed(BuildContext context) { + // Dismiss the action sheet. + Navigator.pop(context); + + Navigator.push(context, + ProfilePage.buildRoute(context: context, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: () => _onPressed(context), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20), + }), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index e66a9fc535..140cd5f5bc 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -172,6 +172,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), labelTime: const Color(0x00000000).withValues(alpha: 0.49), + link: const Color(0xff066bd0), // from "Zulip Web UI kit" listMenuItemBg: const Color(0xffcbcdd6), listMenuItemIcon: const Color(0xff9194a3), listMenuItemText: const Color(0xff2d303c), @@ -262,6 +263,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), labelTime: const Color(0xffffffff).withValues(alpha: 0.50), + link: const Color(0xff00aaff), // from "Zulip Web UI kit" listMenuItemBg: const Color(0xff2d303c), listMenuItemIcon: const Color(0xff767988), listMenuItemText: const Color(0xffcbcdd6), @@ -361,6 +363,7 @@ class DesignVariables extends ThemeExtension { required this.labelMenuButton, required this.labelSearchPrompt, required this.labelTime, + required this.link, required this.listMenuItemBg, required this.listMenuItemIcon, required this.listMenuItemText, @@ -451,6 +454,7 @@ class DesignVariables extends ThemeExtension { final Color labelMenuButton; final Color labelSearchPrompt; final Color labelTime; + final Color link; final Color listMenuItemBg; final Color listMenuItemIcon; final Color listMenuItemText; @@ -536,6 +540,7 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? labelTime, + Color? link, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -616,6 +621,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, labelTime: labelTime ?? this.labelTime, + link: link ?? this.link, listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, listMenuItemText: listMenuItemText ?? this.listMenuItemText, @@ -703,6 +709,7 @@ class DesignVariables extends ThemeExtension { labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, labelTime: Color.lerp(labelTime, other.labelTime, t)!, + link: Color.lerp(link, other.link, t)!, listMenuItemBg: Color.lerp(listMenuItemBg, other.listMenuItemBg, t)!, listMenuItemIcon: Color.lerp(listMenuItemIcon, other.listMenuItemIcon, t)!, listMenuItemText: Color.lerp(listMenuItemText, other.listMenuItemText, t)!, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index d65ac20507..2dd48c0302 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -32,6 +32,7 @@ import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; +import 'package:zulip/widgets/read_receipts.dart'; import 'package:zulip/widgets/subscription_list.dart'; import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; @@ -62,6 +63,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { List? mutedUserIds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool? realmEnableReadReceipts, bool shouldSetServerEmojiData = true, bool useLegacyServerEmojiData = false, Future Function()? beforeLongPress, @@ -77,6 +79,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { eg.initialSnapshot( realmAllowMessageEditing: realmAllowMessageEditing, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + realmEnableReadReceipts: realmEnableReadReceipts, )); store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUsers([ @@ -1155,6 +1158,52 @@ void main() { }); }); + group('ViewReadReceiptsButton', () { + final findButtonInSheet = find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.check_check)); + + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(findButtonInSheet); + await tester.tap(findButtonInSheet); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('smoke', (tester) async { + await setupToMessageActionSheet(tester, + message: eg.streamMessage(), narrow: CombinedFeedNarrow()); + + await tapButton(tester); + + // The message action sheet exits and the view-reactions sheet enters. + // + // This just pumps through twice the duration of the latest transition. + // Ideally we'd check that the two expected transitions were triggered + // and that they started at the same time, and pump through the + // longer of the two durations. + // TODO(upstream) support this in TransitionDurationObserver + await transitionDurationObserver.pumpPastTransition(tester); + await transitionDurationObserver.pumpPastTransition(tester); + + // message action sheet exited + check(find.ancestor(of: find.byIcon(ZulipIcons.check_check), + matching: find.byType(BottomSheet))).findsNothing(); + + // receipts sheet opened + check(find.ancestor(of: find.byType(ReadReceipts), + matching: find.byType(BottomSheet))).findsOne(); + }); + + testWidgets('realm-level read receipts disabled -> button is absent', (tester) async { + await setupToMessageActionSheet(tester, + message: eg.streamMessage(), + narrow: CombinedFeedNarrow(), + realmEnableReadReceipts: false); + + check(findButtonInSheet).findsNothing(); + }); + }); + group('StarButton', () { Future tapButton(WidgetTester tester, {bool starred = false}) async { // Starred messages include the same icon so we need to diff --git a/test/widgets/read_receipts_test.dart b/test/widgets/read_receipts_test.dart new file mode 100644 index 0000000000..a716fc2985 --- /dev/null +++ b/test/widgets/read_receipts_test.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/read_receipts.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + late FakeApiConnection connection; + late TransitionDurationObserver transitionDurationObserver; + + Future setupReceiptsSheet(WidgetTester tester, { + required int messageId, + required List users, + ValueGetter>? prepareReceiptsResponseSuccess, + ValueGetter? prepareReceiptsResponseError, + }) async { + assert((prepareReceiptsResponseSuccess == null) != (prepareReceiptsResponseError == null)); + + addTearDown(testBinding.reset); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + + final message = eg.streamMessage(id: messageId); + final stream = eg.stream(streamId: message.streamId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + + transitionDurationObserver = TransitionDurationObserver(); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: MessageListPage(initNarrow: CombinedFeedNarrow()))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(MessageContent)); + await transitionDurationObserver.pumpPastTransition(tester); + + connection.prepare( + json: prepareReceiptsResponseSuccess == null ? null + : GetReadReceiptsResult(userIds: prepareReceiptsResponseSuccess()).toJson(), + httpException: prepareReceiptsResponseError == null ? null + : prepareReceiptsResponseError(), + delay: transitionDurationObserver.transitionDuration + + const Duration(milliseconds: 100)); + + await tester.tap(find.byIcon(ZulipIcons.check_check)); + await transitionDurationObserver.pumpPastTransition(tester); + + check(find.ancestor(of: find.byType(ReadReceipts), + matching: find.byType(BottomSheet))).findsOne(); // receipts sheet opened + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('Read receipts')).findsOne(); + check(find.text('Close')).findsOne(); + + await tester.pumpAndSettle(); + } + + Finder findUserItem(String fullName) => + find.widgetWithText(ReadReceiptsUserItem, fullName); + + group('success', () { + testWidgets('message read by many people', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponseSuccess: () => [1, 2]); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('This message has been read by 2 people:', + findRichText: true)).findsOne(); + check(findUserItem('User 1')).findsOne(); + check(findUserItem('User 2')).findsOne(); + }); + + testWidgets('message read by one person', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponseSuccess: () => [1]); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('This message has been read by 1 person:', + findRichText: true)).findsOne(); + check(findUserItem('User 1')).findsOne(); + check(findUserItem('User 2')).findsNothing(); + }); + + testWidgets('message read by no one', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupReceiptsSheet(tester, messageId: 100, users: [user1, user2], + prepareReceiptsResponseSuccess: () => []); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('No one has read this message yet.')).findsOne(); + check(findUserItem('User 1')).findsNothing(); + check(findUserItem('User 2')).findsNothing(); + }); + + testWidgets('tapping user item opens their profile', (tester) async { + final user = eg.user(userId: 1, fullName: 'User 1'); + await setupReceiptsSheet(tester, messageId: 100, users: [user], + prepareReceiptsResponseSuccess: () => [1]); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + await tester.tap(findUserItem('User 1')); + await transitionDurationObserver.pumpPastTransition(tester); + check(find.byWidgetPredicate((widget) => widget is ProfilePage && widget.userId == 1)) + .findsOne(); + }); + }); + + testWidgets('failure', (tester) async { + await setupReceiptsSheet(tester, messageId: 100, users: [], + prepareReceiptsResponseError: () => SocketException('failed')); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages/100/read_receipts'); + + check(find.text('Failed to load read receipts.')).findsOne(); + }); +}