diff --git a/docs/reviews/messaging/01-menu-dark.png b/docs/reviews/messaging/01-menu-dark.png new file mode 100644 index 0000000..7638e5d Binary files /dev/null and b/docs/reviews/messaging/01-menu-dark.png differ diff --git a/docs/reviews/messaging/01-menu-light.png b/docs/reviews/messaging/01-menu-light.png new file mode 100644 index 0000000..65a303c Binary files /dev/null and b/docs/reviews/messaging/01-menu-light.png differ diff --git a/docs/reviews/messaging/02-delete-confirmation-dark.png b/docs/reviews/messaging/02-delete-confirmation-dark.png new file mode 100644 index 0000000..24e7e60 Binary files /dev/null and b/docs/reviews/messaging/02-delete-confirmation-dark.png differ diff --git a/docs/reviews/messaging/02-selection-light.png b/docs/reviews/messaging/02-selection-light.png new file mode 100644 index 0000000..5623ae2 Binary files /dev/null and b/docs/reviews/messaging/02-selection-light.png differ diff --git a/docs/reviews/messaging/03-picker-large-light.png b/docs/reviews/messaging/03-picker-large-light.png new file mode 100644 index 0000000..c31b215 Binary files /dev/null and b/docs/reviews/messaging/03-picker-large-light.png differ diff --git a/docs/reviews/messaging/03-picker-light.png b/docs/reviews/messaging/03-picker-light.png new file mode 100644 index 0000000..305783b Binary files /dev/null and b/docs/reviews/messaging/03-picker-light.png differ diff --git a/docs/reviews/messaging/03-reactions-dark.png b/docs/reviews/messaging/03-reactions-dark.png new file mode 100644 index 0000000..3f9841a Binary files /dev/null and b/docs/reviews/messaging/03-reactions-dark.png differ diff --git a/docs/reviews/messaging/03-remote-confirmation-dark.png b/docs/reviews/messaging/03-remote-confirmation-dark.png new file mode 100644 index 0000000..718b2df Binary files /dev/null and b/docs/reviews/messaging/03-remote-confirmation-dark.png differ diff --git a/docs/reviews/messaging/03-remote-deleted-light.png b/docs/reviews/messaging/03-remote-deleted-light.png new file mode 100644 index 0000000..4821d0b Binary files /dev/null and b/docs/reviews/messaging/03-remote-deleted-light.png differ diff --git a/docs/reviews/messaging/04-composer-dark.png b/docs/reviews/messaging/04-composer-dark.png new file mode 100644 index 0000000..b7e0e9a Binary files /dev/null and b/docs/reviews/messaging/04-composer-dark.png differ diff --git a/docs/reviews/messaging/04-menu-light.png b/docs/reviews/messaging/04-menu-light.png new file mode 100644 index 0000000..b9174d3 Binary files /dev/null and b/docs/reviews/messaging/04-menu-light.png differ diff --git a/docs/reviews/messaging/README.md b/docs/reviews/messaging/README.md new file mode 100644 index 0000000..7f13b04 --- /dev/null +++ b/docs/reviews/messaging/README.md @@ -0,0 +1,78 @@ +# Messaging review + +Four stacked draft changes implement the held-message menu, local selection and +deletion, encrypted reactions and sender-requested deletion, and composer/gesture +polish. Merge in that order. This is the requested messaging scope, not a claim of +complete Signal feature parity. Quoted replies, message editing and attachments +are not part of these changes. + +The implementation extends Fireplace's Flutter widgets and local store. Signal's +[iOS](https://github.com/signalapp/Signal-iOS) and +[Android](https://github.com/signalapp/Signal-Android) applications informed the +interaction reference; no native source text is included. Colors and the send +button use Fireplace's theme. The +[protocol decision](../../decisions/0021-reactions-and-remote-delete.md) describes +compatibility, ordering, linked-device copies, deletion authorization and failure +recovery. Old clients cannot honor the new controls. + +## Screenshots + +All images are actual Flutter renders of fictional UiFixture data. The first two +stages were captured from their own fresh checkouts; later images show the final +stack. The installed Windows emoji font is a render-only fallback and is not +bundled. Native emoji appearance and gesture feel still need physical iOS/Android +device review. + +| State | Render | +| --- | --- | +| Initial menu, light | [Image](01-menu-light.png) | +| Initial menu, dark | [Image](01-menu-dark.png) | +| Multi-select | [Image](02-selection-light.png) | +| Local deletion confirmation | [Image](02-delete-confirmation-dark.png) | +| Shared reactions | [Image](03-reactions-dark.png) | +| Expanded reaction picker | [Image](03-picker-light.png) | +| Enlarged-text reaction picker | [Image](03-picker-large-light.png) | +| Remote deletion confirmation | [Image](03-remote-confirmation-dark.png) | +| Deleted-message placeholder | [Image](03-remote-deleted-light.png) | +| Final held-message menu | [Image](04-menu-light.png) | +| Final text composer | [Image](04-composer-dark.png) | + +## Validation + +Validation uses pinned Flutter 3.47.6, UTC, and fresh local clones for each draft. + +| Check | Result | +| --- | --- | +| Menu draft | Format/analyze clean; 24 targeted tests; 6 renders | +| Local deletion draft | Format/analyze clean; 19 targeted tests; 6 renders | +| Encrypted controls draft | Format/analyze clean; 11 targeted tests | +| Full stack, non-timing suite | 811 passed, 1 existing skip, including randomized chat simulation | +| Timing suite, run alone with concurrency 1 | 16 passed | +| Final bubble/selection/inventory checks | 30 passed after the last placement change | +| Enlarged picker and reaction interactions | 6 passed, including the additional enlarged-text test | +| Render catalog | 152 cases passed; all 152 final PNG hashes matched the fresh checkout | +| Layer checker and its tests | Passed; 10 checker tests passed | +| Android build refusal script | All 8 refusal checks passed; no signing/build performed | +| Separate backend rules suite | 74 passed; no rule changes | + +The full suite ran at `a82858e`; subsequent presentation fixes were checked with +the targeted suites above and repeated renders. No protocol code changed after +the full run. Format and `flutter analyze` were also repeated after these fixes. + +The backend suite ran from the existing separate backend checkout, with +`firebase emulators:exec --only firestore --project demo-fireplace-test`, a +temporary local config using unused ports, and `npm test` in its `firebase` +directory. No production endpoint, deployment, rule edit or live account was +used. Permission-denied logs are expected from rejection tests. + +Render reproduction: set `TZ=UTC` and +`RENDER_EMOJI_FONT=C:/Windows/Fonts/seguiemj.ttf`, then run +`flutter test scripts/render/render_screens_test.dart`. Compare SHA-256 hashes +of `build/renders/*.png` in identical font/SDK environments. +The final catalog hashes are in [render-sha256.csv](render-sha256.csv). + +## AI assistance + +OpenAI Codex wrote the implementation, tests, design note and review material. +Human review, physical-device acceptance and CLA confirmation remain pending. +Nothing was merged or deployed. diff --git a/docs/reviews/messaging/render-sha256.csv b/docs/reviews/messaging/render-sha256.csv new file mode 100644 index 0000000..03086ad --- /dev/null +++ b/docs/reviews/messaging/render-sha256.csv @@ -0,0 +1,153 @@ +"Name","Hash" +"dark-auth-error.png","84A4EC4AE53371387C26542EFAECFA1165CA8C369765246E6C4AB460D165E3F3" +"dark-blocked-people-empty.png","2394BA4BB818B3F9DD640E48A0E607513397A1F8A8A9BC6EE71B2DCC14427B40" +"dark-blocked-people.png","C9C6F99ED5C91597EFFFA9081135151A72A419950C9E76744BF8038F507A47CD" +"dark-chat-back-unread.png","BFF2316C9F4DB971BD701883F8842EA633333963B0A40C0EF3006478D9CAA44E" +"dark-chat-colors.png","4BE1BBD8D56B6EA15A3C2C555FEADAB761557882D349D2D148A715D56F0928ED" +"dark-chat-details.png","2E90B0F2E0674B2B4E9E0E4282B448352B81A187885D7677B8DCDF1D6C186C9F" +"dark-chat-landscape-large.png","C2439B837A1978CFDDC8D0D01ABCB922C3A761AE68700BAADFCCC708F63B7F30" +"dark-chat-list-empty.png","876D5A1BF4C8D974E92056C8F90153F66A77D8BDF3475CAC58CA8CBF9B789422" +"dark-chat-list-error.png","2E82FED39291402C12951DA99BEADC32F75D10814067D1AA48C68EC1027206C0" +"dark-chat-list-loading.png","5D5CFD54B68A2C8D0DDA5F693B855599894E5445B5BC809A7FE0923B57F18B86" +"dark-chat-list-muted.png","18F925B711C4D4DFEED477FD3B51711605AA17168806D0F4ECCF054E2E3E15F0" +"dark-chat-list-narrow-large.png","6E9443C118B8DA1CBFA48BD897892C0D306980A714768970599066BA6531C38C" +"dark-chat-list-search-empty.png","DADCE7687C8350F3F3768D5F12196967C6983275A60C7DF508EE2334B1AA91BD" +"dark-chat-list.png","43469E203004C6AFEC39CC1E073D7F6433303656F005F485F8018624E2E58370" +"dark-chat-narrow-keyboard.png","94BD1CE531A1A7D2E9BD0681F8FD7C3ED1DE724F93CCD4C35DADB80D1A224503" +"dark-components.png","271B3F65732586D4B1E739AA37EDFCEA20C4D0537C27F5153AD8A9200F297519" +"dark-composer-limit-landscape-keyboard.png","08B6A0B4D996A2D3FCDD3B1FE0DDFBCB0E0FC4A424EC3E16907B82300B2B6D6A" +"dark-composer-limit-narrow-large.png","4ED22EAAF14590C2F855F5838AD0B9CCF1D11C086C70FE9B388A1E91C5398751" +"dark-composer-near-limit.png","D75C4E6083E4C0F285DBE0C6849CA0A77E2CD052505AFAAFA3EA3BEA182704C2" +"dark-conversation-blocked.png","076DE3573762F6B8555AC8E0D8255F3A2C38924D692E5DFF11BF83CCE6FF8968" +"dark-conversation-composing.png","A731EA4D769FD00DE09BA598A2B0B6E77DDCA51838D11CB60FD1090FA2F72AB6" +"dark-conversation-empty.png","72EEB883B32FF074748C1AECE63507867537DF0C4B7F961DC3267CEAC1740917" +"dark-conversation-error.png","18F55BA68768F41B98DE63FB2203D784CE5E19ACAF0E3D6F12C5F4E783A44FA8" +"dark-conversation-sending.png","5DAD840E8301C53428F4E786A29B49F5E438F25B0323E5DD1F5699CF1A11C7AD" +"dark-conversation.png","82673C77FC9C9B4BC606FE8156CA0380CA995EBE74AEA70559B9A3B235444591" +"dark-delete-account.png","86261BE524E02CB94C3412E901444B14037DD56554AE605648C65A7DE55F5930" +"dark-deletion-resume.png","CF677C56D90EDF1FE2F0CFD6264F0155D3AB240F1B6B59A882B10EE0CAA55A1C" +"dark-devices.png","5EFB162CF2C5B0A48F3AF306B30BDFB622926E93E5ED0E9B31DA548DB81D8932" +"dark-formatted-message.png","BC151D17774EE9D7805DAB6C0DA1482B6803A17257E2FD302AA4CAB64812A8CB" +"dark-forward-message.png","50E4F65F19518481EBD9DB2A9F8E9857F549D3F48E86A86FE8B89EFBE4BF591D" +"dark-global-message-search.png","391ED3187AEDD96DCA251ACD4B6329661C2F6317F36F173D4A54B1287CE66F57" +"dark-identity-held.png","DCB1EC77055426B1DEFF7978C3933CD5D94AEF60BA1120F21CF40C0DBFFE869A" +"dark-link-approval-code.png","BCE063AD2355B667843B6CECCCBEBA3499485C5A691012FF63AB0863DF4AE019" +"dark-link-device-landscape.png","6F5B3AE5A560FDC4147055EEA15E1C339ED299E5ED46A0AF5F604BD1FEAE62F3" +"dark-link-device-narrow-large.png","71BA0BBED2A6285EAA4EFC153752B9FBE25924A1D97E689B834394D629997E93" +"dark-link-device.png","15AE504F22458B695BEDE68AF462356E98F9AE3816AEFD22CB23625900297631" +"dark-loading-landscape-large.png","D952CE27C4D9FEAF08F608EC12A34DCE259E71BD404C308A349F262CDDFB0DA7" +"dark-loading.png","B4D8FA4D410F66D800C17B2C8450DF6C8A28EB174129C4D1956DBDEDD7E430CD" +"dark-message-actions-incoming.png","8E2DB023E5566725673353DB8F76E729AC02EB088EB804E3D5C53F1F81087BBD" +"dark-message-actions-narrow-large.png","1D384EA23BB8C64C058C7EBE84E1C2DF9162932AA9F2DE2F7CAA038C03583DD5" +"dark-message-actions-outgoing.png","185B15816687FDC832C737DF906F0779D8D9B7298D94BDD8CD3860FF135F49FC" +"dark-message-not-confirmed.png","E9493D5B66C9E94B17BA2F378F5A898B099C1E42950C79613B7B178B7FBD23EB" +"dark-message-report.png","769848165B366A20CD2F9E8EA481AF358FDEC26E138F03CE8587C8B61D03C846" +"dark-message-request.png","C31AE9D2D777A07E0B07BFFE172EC9E07E10798065623B0E32A5DB076DFB2BFA" +"dark-messages-delete-confirmation.png","2E0D576A9C5352472BD579E8193F0A0A8A998AA9292F2D733F6F78BD143E8EC0" +"dark-messages-deleted.png","51116398CBEB3401C09826E2755E3B9CFF25EF3D86AC3331DF6FF6D76E46304A" +"dark-messages-reaction-counts.png","4796A08369A5B68E53C98DC51A1B1B76BBDC4CE899843577200350BBEFE199E2" +"dark-messages-reaction-details.png","07DFAF1735DEE4DECC64939EF0822A856AA564EF4631E9EE25BF0A983183B47E" +"dark-messages-reaction-picker-large.png","564A425EC3E7EF74C6869A0C45D64247DB0B3C14E955E59CE4DC1A10B8E935B6" +"dark-messages-reaction-picker.png","FD3E8A6030F139CFB58B64C345493D262E89BAFC656A63B06B7C13B2EFE73D51" +"dark-messages-remote-delete-confirmation.png","82FD81C08CAE4A54E2EF42DF91926FF2FDA8436C99A640CAE21A527A781D1A0F" +"dark-messages-remote-deleted.png","FC3E0499E9275E83FDBA2F5932E1326FC0ACA0F4A4CB2D32831725620A4AFE53" +"dark-messages-selected.png","7185DCF46A8B62BEB801A932B4095B645C8B2388FF3EBB37342968893F57E162" +"dark-new-device.png","BEC29C3E2C253BD25C28C78BF8482FD92C823D3C45FE889D0E4C58A52309094E" +"dark-notification-no-preview.png","1C3BDCF06AF3A08C10E8DA521B725DCA7AA70783FE1F991FDA6D81D4FACD2EC8" +"dark-notification-preview.png","F6C42916BAB52B57C8FF522478B35F3438DEFB3A247FC632E7414846BC375D3D" +"dark-oversized-message.png","80FCD60F2C0673EE6D978459618265AD6052794CCB831D905F89148F3D4AE81D" +"dark-password-dialog.png","03122C9CBEAAFE61CA642FDF015FACA2D44EE686385A2DFAF30AD0DBB7633465" +"dark-privacy-and-terms.png","C7D833187A198B7C9C401D7E1CE56D0D1EDE308441D814F2C036E7CA3F28A445" +"dark-recovery-example-key.png","B5D5000EE213E6315DFF7CE8FA006CF64D7B83FD21CCA3095DEC2EAC3EF95737" +"dark-recovery-replace-confirmation.png","DBB70949F03968BCF63C501983DCA1EEE0A6F0F7C983DC447D0FB16DBB993886" +"dark-recovery.png","1AD26DEDA2CCB1FC1AD056DF254FB3A25FB7CFFF42BA5E26C23473EB6FFC8453" +"dark-report-dialog.png","F99FC0D17354BCBFC6133140A69AF995E7C965940B9B3FD6A48E834569EFEE08" +"dark-request-allowance-reached.png","442B36CBB1E90F3E0CEF6230F8BBD17BC698B469AD22E8A251C4558DF861EDF9" +"dark-requests.png","BC5254AA7B3FABC8DFDEDB5EEB403688C416E60C706933ABE230900D9AAC4F22" +"dark-search-message-location.png","339A93F91A692C2A1E6B4B64195DEB53007FF15D8766CF3BE2A3D6475BE8B37F" +"dark-select-message-text-keyboard.png","84BE438650FF105E60A9727EA7339D62F9BB2826B3D61CE2B1BF4C481BFB652A" +"dark-select-message-text.png","F64267DEDC38EF80C9E97DD196BAEC703696C7CEE08BBF17CA94A28C7B5B098E" +"dark-sent-local-save-failed.png","8BB526F37592F04BC89AFA5A16C8025B3F636BD483E298D12A87051BAC79CE1B" +"dark-settings.png","0F545791649B0A3C31B19B6F671BE8101791799576DEC721F7B8079FA99B2834" +"dark-sign-in.png","A3E60E44A2974655DC0122A522963AAFFC47287810616BA40D8F54423A2C5B37" +"dark-sign-up-narrow-large.png","C93BB5F3B56BBDC553526A81300B325410CA2E7F63AB6F84A51F5DB458F45371" +"dark-sign-up.png","132C54BF694DD5F8EC4BC494433185C5816DCB3F27A96B00BD112EDED8210C4B" +"dark-theme-picker.png","A16CE4D04FD9E9AE8AD14E8D9702BB0A52A4DA7F0A113079E85AD38875464525" +"dark-verification-verified.png","3C607316B3F8B8CEE647C62B536D563CC95BB50537E199BC2CF79927D5E0A79A" +"dark-verification.png","704143EB63BD2945D9D2A2B339714ECC57A342D80637C359749D90203E20DC64" +"light-auth-error.png","3D83CDABD8E81B6FC54559218B9DA8C2A7AFD7A83706B9C3C181BEEC2E041DE0" +"light-blocked-people-empty.png","D99A4568199EECCFF096317B3EB20F2D7E7E188BA7A5D786DB5B6F9E514F234D" +"light-blocked-people.png","273BB3B21571F8FBFA10D73C552A8AF4BED2D30418632445B5044969BE08A193" +"light-chat-back-unread.png","323E9F82AD3C032FBA779B503E7599E1EF3C985F82F66A97EC508B1B824FAADD" +"light-chat-colors.png","73DD78E67DCBC27A5D82093DFEBA8B04BA8127F3F1E6CDB28015751361607E89" +"light-chat-details.png","6AE1927D86681CA827C6AAFC6A761DC72039F0D89C4B2774424DECD452245DAA" +"light-chat-landscape-large.png","37BE9BAB15A8CB05EFC7EE7F29E24D76899051E7940680297535E6E0F832BA8F" +"light-chat-list-empty.png","CF23F743E9E3D783E02CE990D460F65D28A42C22DC6EC140C0BA99E17DD15AC9" +"light-chat-list-error.png","FFFB22D03627AC83058599F580B2A6873E05A3C78FF2CD5CB2D72ECC4D0EE162" +"light-chat-list-loading.png","978499F5F91ACB17CED5F8856D2E19E6ADD9AD90E1A5688BAB8CCCFEAB46371F" +"light-chat-list-muted.png","9004133448D699DD94E4F3A03D1F52975ADD99890774C5B3D86D918968BB17F2" +"light-chat-list-narrow-large.png","45E23184E55EDD902AA224D6A045201BBB72E00C44C143BEB7D440BB8C4EB0DE" +"light-chat-list-search-empty.png","B7A1E78A1E871D1433228BED8972B90921B6C3966C395E17FA323A4ED8BE2B65" +"light-chat-list.png","CDC8254448555070FB5C5FF26302BF594E3EB0D662BA802153188CEE4FFF0638" +"light-chat-narrow-keyboard.png","E82DE7508BB81AD0972C18393DA6B488530C079354A1466AA868C3E004C4538F" +"light-components.png","F69A729CDD3FC44446642C6E173B22A2CEA07326B31ABB52301258DDBB0F7919" +"light-composer-limit-landscape-keyboard.png","92E8862A680BE8D564C6D13DF64874234449DC597B54C924F946E44EED86C9EA" +"light-composer-limit-narrow-large.png","B858D1037D105636BE5F864D8E7C831AABE2307673FA73BFB7745394C1A636C5" +"light-composer-near-limit.png","B38F4AEBB6A26A08298C65C6975BE3FEE90BEA8A72E51E83C7FC7273051F602C" +"light-conversation-blocked.png","15B147BBEF3D502833D99B824D1D40C41B22E36E0D7F9AAFB36F8D8F8E19B3D4" +"light-conversation-composing.png","CB550FD2459F25022C5EFE763D30F0AE208BE2BFEA57BD0F2843676DAD804883" +"light-conversation-empty.png","251EFB5D1CC5E679DC080979A0F901DA255A0C347D62824C7F2748948BFEC8C0" +"light-conversation-error.png","25795248009830816F3ED176E769416EC076DDD387F6E63D506D41FB1E52903D" +"light-conversation-sending.png","16387A96F9AF2DC0AE216E4A5FA3ACCE737E26F88F1502F4DF1754D7779FB68A" +"light-conversation.png","6AEC23D369F2515CB2A8399F7B06AABB2DFEE5FD0E2BB67653D0DB0269DFF8FD" +"light-delete-account.png","3FBCE23FB5FFD6D1249843AFA826FC32EA84EF164EB0E103A96AF603BA6DEAD9" +"light-deletion-resume.png","3E68119435D930D5FE34E7D7E16FE44F5C0348B435CC24686DE67CB10DF9CACC" +"light-devices.png","3DEDD04A1C59F10AF6D561E469124979FA68C5CA92A083761C704D6C5D8BF081" +"light-formatted-message.png","6028C7D69FADBCBF4B45E22AE63C0F41A3B03215A54DBA50CD647D06F0F9F81C" +"light-forward-message.png","D55FFC4FBA834792D064FE65268E5F89604F33CCAB82BE865E1FDCBA67B965FC" +"light-global-message-search.png","762F526E9A26C78A228CDE059D0B77BF9584DACFF69E8DCE659ECEAC73A62326" +"light-identity-held.png","E60D4B9EC3EA5C5E09B48226EFE5BA4EB6A536AC8985D953F38BF1DC8D8EF3FA" +"light-link-approval-code.png","1D8A0CE31381B807C83A83FE6218C697BAF30D365D2A5D0B6D224F59AE820B92" +"light-link-device-landscape.png","BF4A4947A746FC2E7C9D8270A3EF4D3E4EE883D0BD9A786CA8BD2B150C91E6B6" +"light-link-device-narrow-large.png","198BA89C0E4A2CF285BB15F11562BC9A1ACD70C140F8DE8DBCE485A5CE871806" +"light-link-device.png","E55FD74B67D34482B9B69112F3A28F3E6E77C50B4B9C21B65B07B477D7846BBC" +"light-loading-landscape-large.png","FD543D369396E75B9A979DD5D6D729BED0DABE469199FA2DBC410912FA55A95A" +"light-loading.png","DD9914C6BD2322A3B87AEA83426EF242760C414278DB611A9B59FC262C76E30C" +"light-message-actions-incoming.png","77D4CDC8D11159205162E65729C7E16CF33C3411CF266BCCF08BD517BBA59FDE" +"light-message-actions-narrow-large.png","37CD1885D6CFE38F6028B61BFA27CE4E977BEAE5CB31C36B327FB4DA0E3C0D6E" +"light-message-actions-outgoing.png","33093D85E072B8823778F1135A86DBCDD31F45D6F41EC4A958DC400E0B31E095" +"light-message-not-confirmed.png","5F7B2197B092CE866AA3C9858D20E7F8A390D928B8743789FEC8F35661A56535" +"light-message-report.png","B50F7C96FEF99CDE0593098C5B01FA78DC9E4A96606F53D05E53E3055DE79D51" +"light-message-request.png","4A00F69CD833085E9770AAB92DE767D62F2C7A3A0FE6813EDA1D7F5746FCA763" +"light-messages-delete-confirmation.png","25CA8E8CA866D467E9B5BBD3BC0DEA63B62A4441AEEC33977087CE63433C6AF9" +"light-messages-deleted.png","5230FE94ADEA086B952FDFDD815DECA020283877087B27349C7A7F64A198929D" +"light-messages-reaction-counts.png","FFE8957D8D612A8775E3659F34C69658890FB19D739DCB18715C6C49138703B4" +"light-messages-reaction-details.png","98BBAD89836CA2CFB0D647D89F52F16F9C1F55B514F6FA2A2B1928708353D12B" +"light-messages-reaction-picker-large.png","F1A7FC053FE38A09C1CCF37F6BF674CCFAAC18832AD4686C77CD91C4FBC2E84F" +"light-messages-reaction-picker.png","74A85747619991F94216716D7CB1006DBBF39634AFE124FDE6A21C56AB84232F" +"light-messages-remote-delete-confirmation.png","584646DD66315C375A013A9C475BA02F40B245B72E44D103FC34AF8D32A8591C" +"light-messages-remote-deleted.png","C76E29C5693D7910650576C1530DD860A414EDF3C95D05B7B46BA4E99B6AC59F" +"light-messages-selected.png","939BB90958FAF1B42AC1512190486BC33D308B88528B79041E3909567B6B8B17" +"light-new-device.png","C4950DEAD4633B92C3E3C4D9DC5F92681FC4E16B0DAA6C305010F2381947AF6C" +"light-notification-no-preview.png","5FC1957E92D8BB964B586868DFA71E56E2B8E60972762D6C7B1F46653A5C07B8" +"light-notification-preview.png","B07BD7184D9A397A5624B02BAAFAF977159E801DAEDE66AC72A0BFBE41546A8A" +"light-oversized-message.png","465DE40B03789A5258B6161DAB4E2050F0E22BB277159CF55F0C36B738F8E946" +"light-password-dialog.png","143FBB0689E35C048132C90B718926B7807FA6A386B462BB0025B55956BD8734" +"light-privacy-and-terms.png","3DC5C9292B993B747BE03557365BEACB68043B973D466B77A501F997C72DC80E" +"light-recovery-example-key.png","40B28699F9307471FF513D17B6D9EF7B27FB10F92A2396422DF4FFC3925B1C92" +"light-recovery-replace-confirmation.png","AADE66753F966C9294B7C33EDBCDE8B9CAD61EFEFFEF818D40F97B28641293E1" +"light-recovery.png","1054EB31A20F7E6A8E3E452616C6533BAF3577511A15DFC91993804CE760D3B9" +"light-report-dialog.png","D049EB9348EECFEFACD397A8EB96BE0B38662C8F73EBF99CCFD9E233DE2AC449" +"light-request-allowance-reached.png","F8CBA3EA8C7B2B9140512F4C3F8FCD0D66B1B797D7AAF4357267ED08249E8780" +"light-requests.png","1B03A6E456A0489C1071AD3AA49E2999E5D580428F1F6C6F49923486B10C61BF" +"light-search-message-location.png","EB0DAF56A711D459B50763272FF943F7C523F60012AA1780D9C62310F57EB2C9" +"light-select-message-text-keyboard.png","D71A1BD66DC843C4CF4AEE87DACAF43FCAE49A314611032EF99FD9A646F0D1CB" +"light-select-message-text.png","F271039EA9029555B11656AB74C885B63A633749B9471A611440EB659EB7A3FD" +"light-sent-local-save-failed.png","D1FC1A56115AA12EE500C18E588B529BEE14FD6FD463A257946E7C5FD6DFD7EF" +"light-settings.png","C9F28563BE58CAFAD49B3E110AE7F1C36796BBE2DC151F856459564DA1D6E76D" +"light-sign-in.png","9C6E942983AEF7A8B997C18C5428B60891E8FED378C64553C18A094B233AE4F6" +"light-sign-up-narrow-large.png","043EBE98DC60DE8EFF50107ABD5526B05F6E7E241BDBD394FD4129F4C38720BC" +"light-sign-up.png","E29DF985860DD0E76E2FE647FFC6A8DC26A0E29E9B2B3CC666E00CB0D9B6D648" +"light-theme-picker.png","69EFAF37D8F6FF1F7BBF41162027B1066F7574D05A859DB640E2C9A95999B806" +"light-verification-verified.png","A9FF43B0D6A687C1A303F0F7657671F64C0463B7ACCA2ACC44B90CAEAFB46CAF" +"light-verification.png","BFC738599EB65829BCAFC75A1E7A12017DB06F82713558DEC837922330D875A3" diff --git a/lib/src/view/chat/chat_composer.dart b/lib/src/view/chat/chat_composer.dart index 3bb1231..c009fd8 100644 --- a/lib/src/view/chat/chat_composer.dart +++ b/lib/src/view/chat/chat_composer.dart @@ -22,6 +22,7 @@ class ChatComposer extends StatelessWidget { required this.contactBusy, required this.send, required this.onUnblock, + this.onInteraction, }); final double keyboardInset; final TextEditingController text; @@ -34,6 +35,7 @@ class ChatComposer extends StatelessWidget { contactBusy; final SendController send; final Future Function() onUnblock; + final VoidCallback? onInteraction; @override Widget build(BuildContext context) { if (blocked) { @@ -71,14 +73,12 @@ class ChatComposer extends StatelessWidget { send.send, }, child: Container( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + padding: const EdgeInsets.fromLTRB(12, 6, 8, 6), decoration: BoxDecoration( color: FireplaceUiTokens.of(context).panel, - border: Border( - top: BorderSide(color: FireplaceUiTokens.of(context).separator), - ), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: ConstrainedBox( @@ -91,6 +91,7 @@ class ChatComposer extends StatelessWidget { child: TextField( key: Key('composer'), controller: text, + onTap: onInteraction, inputFormatters: [MessageInputFormatter()], minLines: 1, maxLines: @@ -99,7 +100,22 @@ class ChatComposer extends StatelessWidget { ? 3 : 6, textCapitalization: TextCapitalization.sentences, + style: const TextStyle(fontSize: 17, height: 1.3), decoration: InputDecoration( + filled: true, + fillColor: FireplaceUiTokens.of(context).incomingBubble, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), counter: messageCharacters(text.text) >= (maxMessageCharacters * .9).floor() @@ -112,24 +128,23 @@ class ChatComposer extends StatelessWidget { hintText: 'Message', contentPadding: EdgeInsets.symmetric( horizontal: 16, - vertical: 10, + vertical: 11, ), ), textInputAction: TextInputAction.newline, ), ), ), - SizedBox(width: 8), + SizedBox(width: 4), // Sending is part of editing; keep the keyboard ready to reply. TextFieldTapRegion( child: IconButton.filled( key: Key('send'), style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: Theme.of(context).colorScheme.primary, + minimumSize: const Size(40, 40), + fixedSize: const Size(40, 40), + shape: const CircleBorder(), + backgroundColor: FireplaceUiTokens.of(context).accent, foregroundColor: Theme.of(context).colorScheme.onPrimary, ), onPressed: diff --git a/lib/src/view/chat/chat_screen.dart b/lib/src/view/chat/chat_screen.dart index 5b47966..b5fef8c 100644 --- a/lib/src/view/chat/chat_screen.dart +++ b/lib/src/view/chat/chat_screen.dart @@ -1,9 +1,10 @@ import 'package:fireplace/src/view/chat/chat_unavailable.dart'; import 'package:fireplace/src/view/chat/message_selection_surface.dart'; +import 'package:fireplace/src/model/chat/message_selection.dart'; import 'package:fireplace/src/view/chat/chat_timeline.dart'; import 'package:fireplace/src/view/chat/chat_app_bar_parts.dart'; import 'package:fireplace/src/view/chat/chat_safety_notices.dart'; -import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat/review_chat_identity.dart'; import 'package:fireplace/src/view/chat/chat_composer.dart'; import 'package:fireplace/src/view/chat/message_menu.dart'; import 'package:fireplace/src/view/chat/route_visibility.dart'; @@ -16,7 +17,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fireplace/src/app/providers.dart'; -import 'package:fireplace/src/crypto/fingerprint.dart'; import 'package:fireplace/src/db/local_messages.dart'; import 'package:fireplace/src/model/chat/chat_service.dart'; import 'package:fireplace/src/view/safety/confirm_block.dart'; @@ -204,56 +204,14 @@ class _ChatScreenState extends ConsumerState { AppSession session, String peerUid, List newIdentityPub, - ) async { - final newFp = await identityFingerprint(newIdentityPub); - final oldPin = await session.keys.pinnedIdentity(peerUid); - final oldFp = oldPin == null ? null : await identityFingerprint(oldPin); - if (!mounted) return; - final trust = await showDialog( - context: context, - builder: (ctx) => UiDialog( - icon: Icon( - Icons.warning_amber_rounded, - color: FireplaceUiTokens.of(context).danger, - size: 40, - ), - title: Text('Security code changed'), - content: SingleChildScrollView( - child: Text( - "This contact's identity key is different from the one you saw " - 'before. That can happen if they reinstalled the app or lost ' - 'their phone, but it can also mean someone is interfering. ' - 'Messages to and from them are on hold.\n\n' - '${oldFp == null ? '' : 'Previous key:\n$oldFp\n\n'}' - 'New key:\n$newFp\n\n' - 'Ask them (in person or over a call you trust) to read you the ' - 'fingerprint under Settings, and only trust the new key if it ' - 'matches.', - ), - ), - actions: [ - TextButton( - key: Key('keepBlocked'), - onPressed: () => Navigator.pop(ctx, false), - child: Text('Keep on hold'), - ), - TextButton( - key: Key('trustNew'), - onPressed: () => Navigator.pop(ctx, true), - child: Text( - 'Trust new key', - style: TextStyle(color: FireplaceUiTokens.of(context).danger), - ), - ), - ], - ), - ); - if (trust == true) { - await session.keys.acceptIdentityChange(peerUid, newIdentityPub); - await session.chat.retryDeferred(); // delivers what was held back - _snack('New key trusted.'); - } - } + ) => reviewChatIdentity( + context, + session, + peerUid, + newIdentityPub, + isMounted: () => mounted, + notice: _snack, + ); Future _blockPeer( AppSession session, @@ -522,6 +480,9 @@ class _ChatScreenState extends ConsumerState { ), ), ChatComposer( + onInteraction: () => ref + .read(messageSelectionProvider(widget.chatId).notifier) + .clear(), keyboardInset: keyboardInset, text: _text, name: name, diff --git a/lib/src/view/chat/chat_timeline.dart b/lib/src/view/chat/chat_timeline.dart index 7845518..af75b55 100644 --- a/lib/src/view/chat/chat_timeline.dart +++ b/lib/src/view/chat/chat_timeline.dart @@ -102,33 +102,65 @@ class ChatTimeline extends ConsumerWidget { color: selection.ids.contains(message.id) ? FireplaceUiTokens.of(context).selectedRow : Colors.transparent, - child: Row( + child: Stack( children: [ - if (selection.active) - Checkbox( - value: selection.ids.contains(message.id), - onChanged: selection.deleting - ? null - : (_) => controller.toggle(message.id), - semanticLabel: 'Select message', + Padding( + padding: EdgeInsets.only( + bottom: + message.reactions.values.any( + (reaction) => reaction.emoji != null, + ) + ? 32 + : 0, ), - Expanded( - child: MessageBubble( - key: anchorFor(message.id), + child: Row( + children: [ + if (selection.active) + Checkbox( + value: selection.ids.contains( + message.id, + ), + onChanged: selection.deleting + ? null + : (_) => + controller.toggle(message.id), + semanticLabel: 'Select message', + ), + Expanded( + child: MessageBubble( + key: anchorFor(message.id), + message: message, + senderName: name, + selecting: selection.active, + selected: selection.ids.contains( + message.id, + ), + onToggleSelection: () => + controller.toggle(message.id), + actions: actionsFor(message), + ), + ), + ], + ), + ), + Positioned( + bottom: 0, + left: selection.active ? 48 : 0, + right: 0, + child: MessageReactions( message: message, - senderName: name, - selecting: selection.active, - selected: selection.ids.contains(message.id), - onToggleSelection: () => - controller.toggle(message.id), - actions: actionsFor(message), + peerName: name, + onSelect: selection.active + ? () => controller.toggle(message.id) + : null, ), ), ], ), ), ), - if (message.outgoing && + if (!selection.active && + message.outgoing && !message.remotelyDeleted && (message.status == MessageStatus.unconfirmed || send.memoryPending[message.id]?.outcome == @@ -150,13 +182,6 @@ class ChatTimeline extends ConsumerWidget { } }, ), - MessageReactions( - message: message, - peerName: name, - onSelect: selection.active - ? () => controller.toggle(message.id) - : null, - ), ], ); }, diff --git a/lib/src/view/chat/message_actions.dart b/lib/src/view/chat/message_actions.dart index 51347c8..6fe40fa 100644 --- a/lib/src/view/chat/message_actions.dart +++ b/lib/src/view/chat/message_actions.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:fireplace/src/view/chat/message_actions_overlay.dart'; + +import 'dart:async'; + +import 'package:fireplace/src/view/chat/message_actions_host.dart'; /// One extensible message action, shared by touch, desktop and accessibility. class MessageAction { @@ -22,12 +25,27 @@ class MessageAction { final String? emoji; } +/// The original long-press recognizer keeps the pointer while the overlay opens. +/// Forward its drag/release so holding and sliding to an action also works. +class MessageMenuGesture { + final highlighted = ValueNotifier(null); + void Function(Offset)? onMove; + VoidCallback? onEnd, onCancel; + void dispose() { + onMove = null; + onEnd = null; + onCancel = null; + highlighted.dispose(); + } +} + Future showMessageActionsMenu( BuildContext context, { required Offset globalPosition, Rect? bubbleRect, Widget? preview, required List actions, + MessageMenuGesture? gesture, }) async { if (actions.isEmpty || !context.mounted) return; // Haptics are an enhancement; platform failures must not prevent the menu. @@ -38,28 +56,39 @@ Future showMessageActionsMenu( final anchor = (bubbleRect ?? Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0)) .shift(-origin); + final previousFocus = FocusManager.instance.primaryFocus; final reducedMotion = MediaQuery.disableAnimationsOf(context); - final chosen = await showGeneralDialog( - context: context, - useRootNavigator: false, - barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - barrierColor: Colors.transparent, - transitionDuration: reducedMotion - ? Duration.zero - : const Duration(milliseconds: 400), - pageBuilder: (ctx, animation, secondary) => MessageActionsOverlay( + final result = Completer(); + final hostKey = GlobalKey(); + late final OverlayEntry entry; + late final LocalHistoryEntry history; + void dismiss() => hostKey.currentState?.dismiss(null); + history = LocalHistoryEntry(onRemove: dismiss, impliesAppBarDismissal: false); + entry = OverlayEntry( + builder: (ctx) => MessageActionsHost( + key: hostKey, anchor: anchor, preview: preview, - actions: actions.where((a) => !a.reaction).toList(), - reactions: actions.where((a) => a.reaction).toList(), - animation: animation, - ), - transitionBuilder: (ctx, animation, secondary, child) => FadeTransition( - opacity: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), - child: child, + actions: actions, + gesture: gesture, + reducedMotion: reducedMotion, + onDisposed: () { + if (!result.isCompleted) result.complete(null); + }, + onClosed: (chosen) { + if (result.isCompleted) return; + result.complete(chosen); + history.remove(); + entry.remove(); + entry.dispose(); + }, ), ); + ModalRoute.of(context)?.addLocalHistoryEntry(history); + Navigator.of(context).overlay!.insert(entry); + if (gesture != null) gesture.onCancel = dismiss; + final chosen = await result.future; + if (previousFocus?.context != null) previousFocus!.requestFocus(); if (context.mounted) chosen?.onSelected(); } diff --git a/lib/src/view/chat/message_actions_host.dart b/lib/src/view/chat/message_actions_host.dart new file mode 100644 index 0000000..222a8f9 --- /dev/null +++ b/lib/src/view/chat/message_actions_host.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:fireplace/src/view/chat/message_actions.dart'; +import 'package:fireplace/src/view/chat/message_actions_overlay.dart'; + +/// An overlay retains the initiating pointer; pushing a route would cancel it. +class MessageActionsHost extends StatefulWidget { + const MessageActionsHost({ + super.key, + required this.anchor, + required this.actions, + required this.reducedMotion, + required this.onClosed, + required this.onDisposed, + this.preview, + this.gesture, + }); + final Rect anchor; + final List actions; + final bool reducedMotion; + final ValueChanged onClosed; + final VoidCallback onDisposed; + final Widget? preview; + final MessageMenuGesture? gesture; + @override + State createState() => MessageActionsHostState(); +} + +class MessageActionsHostState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animation; + bool _closing = false; + @override + void initState() { + super.initState(); + _animation = AnimationController( + vsync: this, + duration: widget.reducedMotion + ? Duration.zero + : const Duration(milliseconds: 400), + )..forward(); + } + + Future dismiss(MessageAction? action) async { + if (_closing) return; + _closing = true; + try { + await _animation.reverse().orCancel; + if (mounted) widget.onClosed(action); + } on TickerCanceled { + /* The owning overlay was disposed. */ + } + } + + @override + void dispose() { + _animation.dispose(); + widget.onDisposed(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BlockSemantics( + child: Semantics( + scopesRoute: true, + namesRoute: true, + label: MaterialLocalizations.of(context).popupMenuLabel, + explicitChildNodes: true, + child: FocusScope( + autofocus: true, + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + dismiss(null), + }, + child: FadeTransition( + opacity: CurvedAnimation( + parent: _animation, + curve: Curves.easeOutCubic, + ), + child: Stack( + children: [ + ModalBarrier( + dismissible: true, + onDismiss: () => dismiss(null), + semanticsLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + ), + MessageActionsOverlay( + anchor: widget.anchor, + actions: widget.actions.where((a) => !a.reaction).toList(), + reactions: widget.actions.where((a) => a.reaction).toList(), + animation: _animation, + preview: widget.preview, + gesture: widget.gesture, + onSelected: dismiss, + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/src/view/chat/message_actions_overlay.dart b/lib/src/view/chat/message_actions_overlay.dart index 0e78d4a..18b0bee 100644 --- a/lib/src/view/chat/message_actions_overlay.dart +++ b/lib/src/view/chat/message_actions_overlay.dart @@ -12,14 +12,18 @@ class MessageActionsOverlay extends StatelessWidget { required this.anchor, required this.actions, required this.animation, + required this.onSelected, this.reactions = const [], + this.gesture, this.preview, }); final Rect anchor; final List actions; final List reactions; + final MessageMenuGesture? gesture; final Animation animation; + final ValueChanged onSelected; final Widget? preview; @override @@ -27,6 +31,33 @@ class MessageActionsOverlay extends StatelessWidget { final media = MediaQuery.of(context); final theme = Theme.of(context); final tokens = theme.extension(); + final targets = { + for (final a in [...reactions, ...actions]) a: GlobalKey(), + }; + final panelKey = GlobalKey(), reactionKey = GlobalKey(); + bool contains(GlobalKey key, Offset point) { + final render = key.currentContext?.findRenderObject(); + return render is RenderBox && + (render.localToGlobal(Offset.zero) & render.size).contains(point); + } + + if (gesture != null) { + gesture!.onMove = (point) { + MessageAction? chosen; + for (final entry in targets.entries) { + if (contains(entry.key.reaction ? reactionKey : panelKey, point) && + contains(entry.value, point)) { + chosen = entry.key; + break; + } + } + gesture!.highlighted.value = chosen; + }; + gesture!.onEnd = () { + final chosen = gesture!.highlighted.value; + if (chosen != null && context.mounted) onSelected(chosen); + }; + } return Stack( children: [ Positioned.fill( @@ -51,39 +82,50 @@ class MessageActionsOverlay extends StatelessWidget { if (reactions.isNotEmpty) LayoutId( id: _Part.reactions, - child: Material( - key: const Key('messageReactionBar'), - elevation: 8, - color: tokens?.panel ?? theme.colorScheme.surface, - borderRadius: BorderRadius.circular(32), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final reaction in reactions) - IconButton( - key: ValueKey('messageAction-${reaction.id}'), - tooltip: reaction.label, - isSelected: reaction.selected, - style: IconButton.styleFrom( - minimumSize: const Size(44, 44), - backgroundColor: reaction.selected - ? tokens?.selectedRow - : null, + child: KeyedSubtree( + key: reactionKey, + child: Material( + key: const Key('messageReactionBar'), + elevation: 8, + color: tokens?.panel ?? theme.colorScheme.surface, + borderRadius: BorderRadius.circular(32), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final reaction in reactions) + MessageActionHighlight( + key: targets[reaction], + action: reaction, + gesture: gesture, + child: IconButton( + key: ValueKey('messageAction-${reaction.id}'), + tooltip: reaction.label, + isSelected: reaction.selected, + style: IconButton.styleFrom( + fixedSize: const Size(48, 48), + padding: EdgeInsets.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + backgroundColor: reaction.selected + ? tokens?.selectedRow + : null, + ), + onPressed: () => onSelected(reaction), + icon: reaction.emoji == null + ? Icon(reaction.icon) + : Text( + reaction.emoji!, + textScaler: TextScaler.noScaling, + style: const TextStyle(fontSize: 28), + ), + ), ), - onPressed: () => Navigator.pop(context, reaction), - icon: reaction.emoji == null - ? Icon(reaction.icon) - : Text( - reaction.emoji!, - textScaler: TextScaler.noScaling, - style: const TextStyle(fontSize: 28), - ), - ), - ], + ], + ), ), ), ), @@ -109,66 +151,75 @@ class MessageActionsOverlay extends StatelessWidget { alignment: anchor.center.dx > media.size.width / 2 ? Alignment.topRight : Alignment.topLeft, - child: Material( - key: const Key('messageActionsPanel'), - elevation: 12, - color: tokens?.panel ?? theme.colorScheme.surface, - borderRadius: BorderRadius.circular(24), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final (index, action) in actions.indexed) ...[ - if (index != 0) - Divider( - height: 1, - thickness: .5, - color: tokens?.separator, - ), - TextButton( - key: ValueKey('messageAction-${action.id}'), - autofocus: index == 0, - onPressed: () => Navigator.pop(context, action), - style: ButtonStyle( - shape: const WidgetStatePropertyAll( - RoundedRectangleBorder(), - ), - minimumSize: const WidgetStatePropertyAll( - Size.fromHeight(48), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), + child: KeyedSubtree( + key: panelKey, + child: Material( + key: const Key('messageActionsPanel'), + elevation: 12, + color: tokens?.panel ?? theme.colorScheme.surface, + borderRadius: BorderRadius.circular(24), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, action) in actions.indexed) ...[ + if (index != 0) + Divider( + height: 1, + thickness: .5, + color: tokens?.separator, ), - foregroundColor: WidgetStatePropertyAll( - action.destructive - ? tokens?.danger ?? theme.colorScheme.error - : tokens?.text ?? - theme.colorScheme.onSurface, - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - action.label, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w400, + MessageActionHighlight( + key: targets[action], + action: action, + gesture: gesture, + child: TextButton( + key: ValueKey('messageAction-${action.id}'), + autofocus: index == 0, + onPressed: () => onSelected(action), + style: ButtonStyle( + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(), + ), + minimumSize: const WidgetStatePropertyAll( + Size.fromHeight(48), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, ), ), + foregroundColor: WidgetStatePropertyAll( + action.destructive + ? tokens?.danger ?? + theme.colorScheme.error + : tokens?.text ?? + theme.colorScheme.onSurface, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + action.label, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: 12), + Icon(action.icon, size: 22), + ], ), - const SizedBox(width: 12), - Icon(action.icon, size: 22), - ], + ), ), - ), + ], ], - ], + ), ), ), ), @@ -181,6 +232,32 @@ class MessageActionsOverlay extends StatelessWidget { } } +class MessageActionHighlight extends StatelessWidget { + const MessageActionHighlight({ + super.key, + required this.action, + required this.gesture, + required this.child, + }); + final MessageAction action; + final MessageMenuGesture? gesture; + final Widget child; + @override + Widget build(BuildContext context) { + if (gesture == null) return child; + return ValueListenableBuilder( + valueListenable: gesture!.highlighted, + builder: (context, highlighted, child) => ColoredBox( + color: identical(highlighted, action) + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: .1) + : Colors.transparent, + child: child, + ), + child: child, + ); + } +} + enum _Part { preview, menu, reactions } class _MessageMenuLayout extends MultiChildLayoutDelegate { diff --git a/lib/src/view/chat/message_menu.dart b/lib/src/view/chat/message_menu.dart index adb44f4..fbcf6be 100644 --- a/lib/src/view/chat/message_menu.dart +++ b/lib/src/view/chat/message_menu.dart @@ -11,6 +11,7 @@ import 'package:fireplace/src/model/chat/message_format.dart'; import 'package:fireplace/src/model/chat/message_limits.dart'; import 'package:fireplace/src/model/chat/message_selection.dart'; import 'package:fireplace/src/model/chat/message_controls.dart'; +import 'package:fireplace/src/model/chat/pending_sends.dart'; import 'package:fireplace/src/view/chat/message_reactions.dart'; /// Shared touch, keyboard and screen-reader actions for one message. @@ -33,7 +34,25 @@ List messageMenuActions( bool available() { if (!isMounted() || owner == null) return false; final summary = ref.read(chatSummaryProvider(chatId())); + final live = ref + .read(messagesProvider(chatId())) + .value + ?.where((m) => m.id == message.id) + .firstOrNull; + final pending = ref + .read(pendingLocalSendsProvider) + .values + .any( + (m) => + m.chatId == chatId() && + m.messageId == message.id && + m.body == message.body, + ); return identical(ref.read(appSessionProvider).value, owner) && + ((live != null && + live.body == message.body && + live.remotelyDeleted == message.remotelyDeleted) || + pending) && summary != null && ref.read(chatActivityProvider.notifier).eligible(summary); } @@ -105,6 +124,7 @@ List messageMenuActions( sheetRef.watch(blockedUidsProvider); sheetRef.watch(hiddenChatsProvider); sheetRef.watch(identityAlertsProvider); + sheetRef.watch(messagesProvider(chatId())); return available() ? child : const Text( diff --git a/lib/src/view/chat/message_reactions.dart b/lib/src/view/chat/message_reactions.dart index 1d5a9d9..6fa4fa1 100644 --- a/lib/src/view/chat/message_reactions.dart +++ b/lib/src/view/chat/message_reactions.dart @@ -119,8 +119,10 @@ class _ReactionPickerState extends State { child: TextButton( key: ValueKey('pickReaction-$emoji'), onPressed: () => Navigator.pop(context, emoji), + style: TextButton.styleFrom(padding: EdgeInsets.zero), child: Text( emoji, + textScaler: TextScaler.noScaling, style: const TextStyle(fontSize: 26), ), ), diff --git a/lib/src/view/chat/review_chat_identity.dart b/lib/src/view/chat/review_chat_identity.dart new file mode 100644 index 0000000..d8f49ea --- /dev/null +++ b/lib/src/view/chat/review_chat_identity.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/crypto/fingerprint.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/widgets/dialog.dart'; + +Future reviewChatIdentity( + BuildContext context, + AppSession session, + String peerUid, + List newIdentityPub, { + required bool Function() isMounted, + required void Function(String) notice, +}) async { + final newFp = await identityFingerprint(newIdentityPub); + final oldPin = await session.keys.pinnedIdentity(peerUid); + final oldFp = oldPin == null ? null : await identityFingerprint(oldPin); + if (!isMounted() || !context.mounted) return; + final trust = await showDialog( + context: context, + builder: (ctx) => UiDialog( + icon: Icon( + Icons.warning_amber_rounded, + color: FireplaceUiTokens.of(context).danger, + size: 40, + ), + title: Text('Security code changed'), + content: SingleChildScrollView( + child: Text( + "This contact's identity key is different from the one you saw " + 'before. That can happen if they reinstalled the app or lost ' + 'their phone, but it can also mean someone is interfering. ' + 'Messages to and from them are on hold.\n\n' + '${oldFp == null ? '' : 'Previous key:\n$oldFp\n\n'}' + 'New key:\n$newFp\n\n' + 'Ask them (in person or over a call you trust) to read you the ' + 'fingerprint under Settings, and only trust the new key if it ' + 'matches.', + ), + ), + actions: [ + TextButton( + key: Key('keepBlocked'), + onPressed: () => Navigator.pop(ctx, false), + child: Text('Keep on hold'), + ), + TextButton( + key: Key('trustNew'), + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'Trust new key', + style: TextStyle(color: FireplaceUiTokens.of(context).danger), + ), + ), + ], + ), + ); + if (trust == true) { + await session.keys.acceptIdentityChange(peerUid, newIdentityPub); + await session.chat.retryDeferred(); // delivers what was held back + notice('New key trusted.'); + } +} diff --git a/lib/src/view/chat/widgets/message_bubble.dart b/lib/src/view/chat/widgets/message_bubble.dart index 76a422f..1728310 100644 --- a/lib/src/view/chat/widgets/message_bubble.dart +++ b/lib/src/view/chat/widgets/message_bubble.dart @@ -11,6 +11,7 @@ import 'package:fireplace/src/model/chat/message_format.dart'; import 'package:fireplace/src/view/chat/message_actions.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; +import 'package:fireplace/src/model/chat/pending_sends.dart'; class MessageBubble extends ConsumerStatefulWidget { const MessageBubble({ @@ -43,6 +44,13 @@ class _MessageBubbleState extends ConsumerState { bool _showAll = false; bool _focused = false; final _actionAnchor = GlobalKey(); + MessageMenuGesture? _gesture; + @override + void dispose() { + _gesture?.onCancel?.call(); + super.dispose(); + } + @override void didUpdateWidget(covariant MessageBubble oldWidget) { super.didUpdateWidget(oldWidget); @@ -59,6 +67,8 @@ class _MessageBubbleState extends ConsumerState { } if (widget.actions.isEmpty || _held) return; setState(() => _held = true); + final gesture = MessageMenuGesture(); + _gesture = gesture; try { final owner = ref.read(appSessionProvider).value; final anchor = _actionAnchor.currentContext?.findRenderObject(); @@ -82,24 +92,41 @@ class _MessageBubbleState extends ConsumerState { previewRef.watch(blockedUidsProvider); previewRef.watch(hiddenChatsProvider); previewRef.watch(identityAlertsProvider); + final live = previewRef + .watch(messagesProvider(widget.message.chatId)) + .value + ?.where((m) => m.id == widget.message.id) + .firstOrNull; + final pending = previewRef + .watch(pendingLocalSendsProvider) + .values + .any( + (m) => + m.chatId == widget.message.chatId && + m.messageId == widget.message.id, + ); if (!mounted || !identical(session, owner) || summary == null || + (live == null && !pending) || !previewRef .read(chatActivityProvider.notifier) .eligible(summary)) { return const SizedBox.shrink(); } return MessageBubble( - message: widget.message, + message: live ?? widget.message, senderName: widget.senderName, preview: true, ); }, ), actions: widget.actions, + gesture: gesture, ); } finally { + _gesture = null; + gesture.dispose(); if (mounted) setState(() => _held = false); } } @@ -176,99 +203,114 @@ class _MessageBubbleState extends ConsumerState { onSecondaryTapDown: widget.actions.isEmpty ? null : (d) => _openMenu(d.globalPosition), - child: Container( - key: ValueKey( - '${widget.preview ? 'heldMessage' : 'messageBubble'}-${message.id}', - ), - constraints: BoxConstraints( - maxWidth: (box.maxWidth * (widget.preview ? 1 : .8)) - .clamp(0.0, 480.0) - .toDouble(), - ), - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: _held - ? Color.alphaBlend(const Color(0x22000000), base) - : base, - borderRadius: bubbleRadius, - ), - // Painted over the bubble, so focus never changes its size. - foregroundDecoration: _focused - ? BoxDecoration( - border: Border.all(color: fg, width: 2), - borderRadius: bubbleRadius, - ) - : null, - // The sender is announced first. The menu actions are also offered to screen readers, as - // custom actions, because a long press is hard to perform with TalkBack or VoiceOver. - child: Semantics( - label: who, - selected: widget.selecting ? widget.selected : null, - customSemanticsActions: { - for (final a - in widget.selecting ? [] : widget.actions) - CustomSemanticsAction(label: a.label): a.onSelected, - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (bad) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 6, - children: [ - Icon(Icons.lock_outline, size: 16, color: fg), - Text( - 'Unreadable message', - style: TextStyle( - color: fg, - fontWeight: FontWeight.w600, + onLongPressMoveUpdate: (d) => + _gesture?.onMove?.call(d.globalPosition), + onLongPressEnd: (_) => _gesture?.onEnd?.call(), + child: Opacity( + opacity: _held ? 0 : 1, + child: Container( + key: ValueKey( + '${widget.preview ? 'heldMessage' : 'messageBubble'}-${message.id}', + ), + constraints: BoxConstraints( + maxWidth: (box.maxWidth * (widget.preview ? 1 : .8)) + .clamp(0.0, 480.0) + .toDouble(), + ), + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: _held + ? Color.alphaBlend(const Color(0x22000000), base) + : base, + borderRadius: bubbleRadius, + ), + // Painted over the bubble, so focus never changes its size. + foregroundDecoration: _focused + ? BoxDecoration( + border: Border.all(color: fg, width: 2), + borderRadius: bubbleRadius, + ) + : null, + // The sender is announced first. The menu actions are also offered to screen readers, as + // custom actions, because a long press is hard to perform with TalkBack or VoiceOver. + child: Semantics( + label: who, + selected: widget.selecting ? widget.selected : null, + customSemanticsActions: { + for (final a + in widget.selecting + ? [] + : widget.actions) + CustomSemanticsAction(label: a.label): a.onSelected, + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (bad) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + children: [ + Icon(Icons.lock_outline, size: 16, color: fg), + Text( + 'Unreadable message', + style: TextStyle( + color: fg, + fontWeight: FontWeight.w600, + ), ), - ), - ], + ], + ), ), - ), - Align( - widthFactor: 1, - alignment: AlignmentDirectional.centerStart, - child: Text.rich( - formatted.span( - TextStyle( - color: fg, - fontSize: 16, - height: 1.4, - fontStyle: bad || message.remotelyDeleted - ? FontStyle.italic - : FontStyle.normal, + Align( + widthFactor: 1, + alignment: AlignmentDirectional.centerStart, + child: Text.rich( + formatted.span( + TextStyle( + color: fg, + fontSize: 17, + height: 1.3, + fontStyle: bad || message.remotelyDeleted + ? FontStyle.italic + : FontStyle.normal, + ), ), + semanticsLabel: formatted.plain, ), - semanticsLabel: formatted.plain, ), - ), - if (oversize) - TextButton( - key: ValueKey('showAll-${message.id}'), - onPressed: widget.selecting - ? widget.onToggleSelection - : () => setState(() => _showAll = !_showAll), - child: Text( - _showAll ? 'Show less' : 'Show all', - style: TextStyle( - color: fg, - decoration: TextDecoration.underline, + if (oversize) + TextButton( + key: ValueKey('showAll-${message.id}'), + onPressed: widget.selecting + ? widget.onToggleSelection + : () => setState(() => _showAll = !_showAll), + child: Text( + _showAll ? 'Show less' : 'Show all', + style: TextStyle( + color: fg, + decoration: TextDecoration.underline, + ), ), ), + const SizedBox(height: 2), + Text( + time, + style: TextStyle( + color: mine ? fg : t.secondaryText, + fontSize: 11, + height: 1.3, + ), ), - const SizedBox(height: 2), - Text( - time, - style: TextStyle(color: fg, fontSize: 12, height: 1.3), - ), - ], + ], + ), ), ), ), diff --git a/scripts/render/README.md b/scripts/render/README.md index b7a1de3..ec733d5 100644 --- a/scripts/render/README.md +++ b/scripts/render/README.md @@ -16,7 +16,7 @@ recording service fakes. Device identifiers, fingerprints, usernames and message are fictional. These fakes cannot validate crypto, Firebase rules or native plugins. There is no Firebase SDK instance or live account connection. -The catalog has 56 states in each theme, including shared components, temporary chat color settings, narrow/enlarged signup and list views, and conversation layouts with keyboard/landscape insets. The core inventory covers: list/data/empty/loading/error, +The catalog has 76 states in each theme, including shared components, temporary chat color settings, narrow/enlarged signup and list views, and conversation layouts with keyboard/landscape insets. The core inventory covers: list/data/empty/loading/error, conversation/data/empty/error, request, requests list, details, verification, settings, devices, recovery, linking, new device, deletion, resumed deletion, sign-in, sign-up, startup and auth error. Each test disposes its widget tree and diff --git a/test/support/ui_fixture.dart b/test/support/ui_fixture.dart index 7915319..654f052 100644 --- a/test/support/ui_fixture.dart +++ b/test/support/ui_fixture.dart @@ -53,6 +53,7 @@ class FixtureChat extends Fake implements ChatService { Completer? startHold; Object? sendError; Object? controlError; + int controlAttempts = 0; final reactions = <(String, String)>[]; final remoteDeletes = []; @@ -81,6 +82,7 @@ class FixtureChat extends Fake implements ChatService { @override Future react(String chatId, String targetId, String emoji) async { + controlAttempts++; if (controlError != null) throw controlError!; reactions.add((targetId, emoji)); final target = await store.get(chatId, targetId); diff --git a/test/view/chat/chat_screen_inventory_test.dart b/test/view/chat/chat_screen_inventory_test.dart index fb055c9..8eb117b 100644 --- a/test/view/chat/chat_screen_inventory_test.dart +++ b/test/view/chat/chat_screen_inventory_test.dart @@ -1955,6 +1955,7 @@ const expected = >>{ '[<\'chatSafetyNotices\'>]', '[<\'composer\'>]', '[<\'latestMessages\'>]', + '[<\'long-11\'>]', '[<\'long-12\'>]', '[<\'long-13\'>]', '[<\'long-14\'>]', @@ -1971,6 +1972,7 @@ const expected = >>{ '[<\'long-25\'>]', '[<\'long-26\'>]', '[<\'long-27\'>]', + '[<\'messageBubble-long-11\'>]', '[<\'messageBubble-long-12\'>]', '[<\'messageBubble-long-13\'>]', '[<\'messageBubble-long-14\'>]', @@ -2063,8 +2065,12 @@ const expected = >>{ '[GlobalKey#identity]', '[GlobalKey#identity]', '[GlobalKey#identity]', + '[GlobalKey#identity]', + '[GlobalKey#identity]', + '[GlobalKey#identity]', '[GlobalObjectKey _MaterialAppState#identity]', '[GlobalObjectKey _WidgetsAppState#identity]', + '[Key <[<\'long-11\'>]>]', '[Key <[<\'long-12\'>]>]', '[Key <[<\'long-13\'>]>]', '[Key <[<\'long-14\'>]>]', @@ -2096,6 +2102,7 @@ const expected = >>{ '[_DeprecatedRawViewKey TestFlutterView#identity]', ], 'texts': [ + '12:12 PM', '12:13 PM', '12:14 PM', '12:15 PM', @@ -2113,6 +2120,7 @@ const expected = >>{ '12:27 PM', '12:28 PM', 'F', + 'History message 11', 'History message 12', 'History message 13', 'History message 14', @@ -2135,6 +2143,7 @@ const expected = >>{ ], 'semantics': [ 'label: Back', + 'label: History message 11', 'label: History message 12', 'label: History message 13', 'label: History message 14', @@ -2167,6 +2176,7 @@ const expected = >>{ 'label: fred', 'label: fred', 'label: fred', + 'label: fred', 'tooltip: Back to chats', 'tooltip: Back to chats', 'tooltip: Send message', diff --git a/test/view/chat/composer_presentation_test.dart b/test/view/chat/composer_presentation_test.dart new file mode 100644 index 0000000..c31a577 --- /dev/null +++ b/test/view/chat/composer_presentation_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; + +import '../../support/ui_fixture.dart'; +import 'long_press_menu_test.dart' show openChat; + +void main() { + testWidgets( + 'rounded composer and circular send use the theme and preserve editing', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + final input = find.byKey(const Key('composer')); + await t.enterText(input, 'A message\nwith another line'); + await t.pump(); + final field = t.widget(input); + expect(field.decoration!.filled, isTrue); + expect( + (field.decoration!.enabledBorder as OutlineInputBorder).borderRadius, + BorderRadius.circular(22), + ); + final button = t.widget(find.byKey(const Key('send'))); + expect(button.style!.shape!.resolve({}), isA()); + expect( + button.style!.backgroundColor!.resolve({}), + FireplaceUiTokens.of(t.element(input)).accent, + ); + await t.tap(find.byKey(const Key('send'))); + await t.pumpAndSettle(); + expect(f.chat.sends, ['A message\nwith another line']); + expect(t.testTextInput.isVisible, isTrue); + }, + ); +} diff --git a/test/view/chat/message_menu_presentation_test.dart b/test/view/chat/message_menu_presentation_test.dart index 0d2c894..6bf8cfc 100644 --- a/test/view/chat/message_menu_presentation_test.dart +++ b/test/view/chat/message_menu_presentation_test.dart @@ -6,6 +6,47 @@ import '../../support/ui_fixture.dart'; import 'long_press_menu_test.dart' show openChat, bubble, action; void main() { + testWidgets( + 'back closes the overlay and a deleted message cannot be copied', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.longPress(bubble('5')); + await t.pumpAndSettle(); + await t.binding.handlePopRoute(); + await t.pumpAndSettle(); + expect(action('copy'), findsNothing); + String? copied; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown( + () => t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await t.longPress(bubble('4')); + await t.pumpAndSettle(); + await f.chat.deleteForEveryone('alice_fred', '4'); + await t.pumpAndSettle(); + expect( + find.text('Looking forward to it. What time works for you?'), + findsNothing, + ); + await t.tap(action('copy')); + await t.pumpAndSettle(); + expect(copied, isNull); + }, + ); for (final id in ['4', '5']) { testWidgets('held bubble $id stays aligned with its menu', (t) async { t.view.physicalSize = const Size(390, 844); diff --git a/test/view/chat/message_menu_test.dart b/test/view/chat/message_menu_test.dart index 0b65fb9..6f24b9e 100644 --- a/test/view/chat/message_menu_test.dart +++ b/test/view/chat/message_menu_test.dart @@ -153,6 +153,7 @@ void main() { (t) async { final f = UiFixture(); await f.seed(); + await f.chat.store.add(message(id: 'target', body: '**Hello**')); addTearDown(f.session.close); var mounted = true; String? copied; @@ -185,6 +186,7 @@ void main() { ref.watch(hiddenChatsProvider); ref.watch(identityAlertsProvider); ref.watch(chatActivityProvider); + ref.watch(messagesProvider('alice_fred')); actions = messageMenuActions( message(id: 'target', body: '**Hello**'), ref: ref, diff --git a/test/view/chat/message_reactions_test.dart b/test/view/chat/message_reactions_test.dart index d066659..bccf6bf 100644 --- a/test/view/chat/message_reactions_test.dart +++ b/test/view/chat/message_reactions_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fireplace/src/model/chat/chat_service.dart'; @@ -6,6 +7,24 @@ import '../../support/ui_fixture.dart'; import 'long_press_menu_test.dart' show openChat, bubble, action; void main() { + testWidgets( + 'hold, slide to an emoji and release reacts without another tap', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + final gesture = await t.startGesture(t.getCenter(bubble('5'))); + await t.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + await t.pumpAndSettle(); + await gesture.moveTo(t.getCenter(action('react-❤️'))); + await t.pump(); + await gesture.up(); + await t.pumpAndSettle(); + expect(f.chat.reactions, [('5', '❤️')]); + expect(action('react-❤️'), findsNothing); + }, + ); testWidgets('quick reaction, replace and remove through reaction details', ( t, ) async { @@ -20,6 +39,10 @@ void main() { await t.pumpAndSettle(); expect(f.chat.reactions, [('5', '❤️')]); expect(find.byKey(const ValueKey('reaction-5-❤️')), findsOneWidget); + final chip = t.getRect(find.byKey(const ValueKey('reaction-5-❤️'))); + final message = t.getRect(bubble('5')); + expect(chip.top, lessThan(message.bottom)); + expect(chip.bottom, greaterThan(message.bottom)); await t.longPress(bubble('5')); await t.pumpAndSettle(); await t.tap(action('react-👍')); @@ -33,6 +56,28 @@ void main() { }); testWidgets('expanded picker sends one selected emoji', (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.longPress(bubble('5')); + await t.pumpAndSettle(); + final bar = t.getRect(find.byKey(const Key('messageReactionBar'))); + final more = t.getRect(action('moreReactions')); + expect(bar.contains(more.topLeft), isTrue); + expect(bar.contains(more.bottomRight), isTrue); + await t.tap(action('moreReactions')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const ValueKey('pickReaction-🥰'))); + await t.pumpAndSettle(); + expect(f.chat.reactions, [('5', '🥰')]); + }); + + testWidgets('large text keeps picker emoji within their touch targets', ( + t, + ) async { + t.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); final f = UiFixture(); await f.seed(); addTearDown(f.session.close); @@ -42,6 +87,10 @@ void main() { await t.ensureVisible(action('moreReactions')); await t.tap(action('moreReactions')); await t.pumpAndSettle(); + final button = t.getSize(find.byKey(const ValueKey('pickReaction-🥰'))); + final emoji = t.getSize(find.text('🥰')); + expect(emoji.height, lessThanOrEqualTo(button.height)); + expect(emoji.width, lessThanOrEqualTo(button.width)); await t.tap(find.byKey(const ValueKey('pickReaction-🥰'))); await t.pumpAndSettle(); expect(f.chat.reactions, [('5', '🥰')]); @@ -101,5 +150,6 @@ void main() { expect(find.textContaining('It may have reached them'), findsOneWidget); expect(find.text('Check status'), findsOneWidget); expect(f.chat.reactions, isEmpty); + expect(f.chat.controlAttempts, 1); }); } diff --git a/test/view/chat/message_selection_test.dart b/test/view/chat/message_selection_test.dart index bb0634d..c7b0803 100644 --- a/test/view/chat/message_selection_test.dart +++ b/test/view/chat/message_selection_test.dart @@ -63,6 +63,10 @@ void main() { await t.pumpAndSettle(); expect(find.byKey(const Key('selectionCount')), findsNothing); expect(await f.chat.store.get('alice_fred', '5'), isNotNull); + await select(t, '5'); + await t.tap(find.byKey(const Key('composer'))); + await t.pumpAndSettle(); + expect(find.byKey(const Key('selectionCount')), findsNothing); }); testWidgets('selection resets when account or access changes', (t) async {